From 08f9d50e669ca4fa160f18a1212799ac06123092 Mon Sep 17 00:00:00 2001 From: MatrixSeven Date: Thu, 18 Sep 2025 18:43:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=A1=8C=E9=9D=A2=E5=85=B1=E4=BA=ABUI/?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .chuan.env | 12 + chuan-next/src/app/HomePage.tsx | 251 +++++++------- .../src/components/ConnectionStatus.tsx | 139 +++++--- chuan-next/src/components/DesktopViewer.tsx | 57 ++-- .../src/components/WebRTCUnsupportedModal.tsx | 175 ++++++---- .../components/webrtc/WebRTCDesktopSender.tsx | 238 ++++++++----- .../hooks/connection/state/webConnectStore.ts | 10 +- chuan-next/src/hooks/connection/types.ts | 8 +- .../src/hooks/connection/useConnectManager.ts | 20 +- .../webrtc/useSharedWebRTCManager.ts | 5 +- .../webrtc/useWebRTCConnectionCore.ts | 135 ++++++-- .../webrtc/useWebRTCTrackManager.ts | 123 +++---- .../connection/ws/useWebSocketConnection.ts | 1 + .../desktop-share/useDesktopShareBusiness.ts | 318 ++++++++++++++---- chuan-next/src/lib/config.ts | 25 +- cmd/config.go | 54 +++ cmd/main.go | 4 +- cmd/router.go | 26 +- cmd/server.go | 48 ++- go.mod | 12 + go.sum | 84 +++++ internal/handlers/handlers.go | 104 ++++++ internal/services/turn_service.go | 234 +++++++++++++ 23 files changed, 1521 insertions(+), 562 deletions(-) create mode 100644 .chuan.env create mode 100644 internal/services/turn_service.go diff --git a/.chuan.env b/.chuan.env new file mode 100644 index 0000000..bf2c2df --- /dev/null +++ b/.chuan.env @@ -0,0 +1,12 @@ +# 文件传输服务器配置 + +# 主服务器配置 +PORT=8080 +# FRONTEND_DIR=./dist + +# TURN服务器配置 +TURN_ENABLED=true +TURN_PORT=3478 +TURN_USERNAME=chuan +TURN_PASSWORD=chuan123 +TURN_REALM=localhost \ No newline at end of file diff --git a/chuan-next/src/app/HomePage.tsx b/chuan-next/src/app/HomePage.tsx index 6eeed52..3afa15e 100644 --- a/chuan-next/src/app/HomePage.tsx +++ b/chuan-next/src/app/HomePage.tsx @@ -1,33 +1,32 @@ "use client"; -import React from 'react'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Upload, MessageSquare, Monitor, Users, Settings } from 'lucide-react'; -import Hero from '@/components/Hero'; -import Footer from '@/components/Footer'; -import { WebRTCFileTransfer } from '@/components/WebRTCFileTransfer'; -import { WebRTCTextImageTransfer } from '@/components/WebRTCTextImageTransfer'; import DesktopShare from '@/components/DesktopShare'; +import Footer from '@/components/Footer'; +import Hero from '@/components/Hero'; import WeChatGroup from '@/components/WeChatGroup'; +import { WebRTCFileTransfer } from '@/components/WebRTCFileTransfer'; import WebRTCSettings from '@/components/WebRTCSettings'; +import { WebRTCTextImageTransfer } from '@/components/WebRTCTextImageTransfer'; import { WebRTCUnsupportedModal } from '@/components/WebRTCUnsupportedModal'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useWebRTCSupport } from '@/hooks/connection'; -import { useTabNavigation, TabType } from '@/hooks/ui'; import { useWebRTCConfigSync } from '@/hooks/settings'; +import { TabType, useTabNavigation } from '@/hooks/ui'; +import { MessageSquare, Monitor, Settings, Upload, Users } from 'lucide-react'; export default function HomePage() { // WebRTC配置同步 useWebRTCConfigSync(); - + // 使用tab导航hook - const { - activeTab, - handleTabChange, + const { + activeTab, + handleTabChange, confirmDialogState, closeConfirmDialog } = useTabNavigation(); - + // WebRTC 支持检测 const { webrtcSupport, @@ -54,125 +53,133 @@ export default function HomePage() { - {/* WebRTC 支持检测加载状态 */} - {!isChecked && ( -
-
-
-
+ {/* WebRTC 支持检测加载状态 */} + {!isChecked && ( +
+
+
+
+
+

正在检测浏览器支持...

-

正在检测浏览器支持...

-
- )} + )} - {/* 主要内容 - 只有在检测完成后才显示 */} - {isChecked && ( -
- {/* WebRTC 不支持时的警告横幅 */} - {!isSupported && ( -
-
-
-
- - 当前浏览器不支持 WebRTC,功能可能无法正常使用 - + {/* 主要内容 - 只有在检测完成后才显示 */} + {isChecked && ( +
+ {/* WebRTC 不支持时的警告横幅 */} + {!isSupported && ( +
+
+
+
+
+
+
+
+ + 浏览器兼容性提醒 + + + 当前浏览器不支持 WebRTC,部分功能可能无法正常使用 + +
+
+
-
-
- )} + )} - - {/* Tabs Navigation - 横向布局 */} -
- - - - 文件传输 - 文件 - {!isSupported && *} - - - - 文本消息 - 消息 - {!isSupported && *} - - - - 共享桌面 - 桌面 - {!isSupported && *} - - - - 微信群 - 微信 - - - - 中继设置 - 设置 - - - - {/* WebRTC 不支持时的提示 */} - {!isSupported && ( -

- * 需要 WebRTC 支持才能使用 -

- )} -
+ + {/* Tabs Navigation - 横向布局 */} +
+ + + + 文件传输 + 文件 + {!isSupported && *} + + + + 文本消息 + 消息 + {!isSupported && *} + + + + 共享桌面 + 桌面 + {!isSupported && *} + + + + 微信群 + 微信 + + + + 中继设置 + 设置 + + - {/* Tab Content */} -
- - - + {/* WebRTC 不支持时的提示 */} + {!isSupported && ( +

+ * 需要 WebRTC 支持才能使用 +

+ )} +
- - - + {/* Tab Content */} +
+ + + - - - + + + - - - + + + - - - -
- -
- )} + + + + + + + +
+ +
+ )}
diff --git a/chuan-next/src/components/ConnectionStatus.tsx b/chuan-next/src/components/ConnectionStatus.tsx index de87f7c..c9cc4f5 100644 --- a/chuan-next/src/components/ConnectionStatus.tsx +++ b/chuan-next/src/components/ConnectionStatus.tsx @@ -24,22 +24,7 @@ const getConnectionStatus = (currentRoom: { code: string; role: Role } | null) = const isConnecting = connection?.isConnecting || false; const error = connection?.error || null; const currentConnectType = connection?.currentConnectType || 'webrtc'; - - if (error) { - return { - type: 'error' as const, - message: '连接失败', - detail: error, - }; - } - - if (isConnecting) { - return { - type: 'connecting' as const, - message: '正在连接', - detail: '建立房间连接中...', - }; - } + const isJoinedRoom = connection?.isJoinedRoom || false; if (!currentRoom) { return { @@ -49,35 +34,83 @@ const getConnectionStatus = (currentRoom: { code: string; role: Role } | null) = }; } - // 如果有房间信息但WebSocket未连接,且不是正在连接状态 - // 可能是状态更新的时序问题,显示连接中状态 - if (!isWebSocketConnected && !isConnecting) { + if (error) { return { - type: 'connecting' as const, - message: '连接中', - detail: '正在建立WebSocket连接...', + type: 'error' as const, + message: '连接失败', + detail: error, }; } - if (isWebSocketConnected ) { - // 根据连接类型显示不同信息 - if (currentConnectType === 'websocket') { + + if (currentConnectType === 'websocket') { + if (isWebSocketConnected && isJoinedRoom) { return { type: 'connected' as const, message: 'P2P链接失败,WS降级中', detail: 'WebSocket传输模式已就绪', }; } + return { + type: 'room-ready' as const, + message: '房间已创建', + detail: '等待对方加入并建立WS连接...', + }; } - if (isWebSocketConnected && isPeerConnected) { + + + if (isConnecting) { + return { + type: 'connecting' as const, + message: '正在连接', + detail: '建立房间连接中...', + }; + } + + // 如果有房间信息但WebSocket未连接,且不是正在连接状态 + // 可能是状态更新的时序问题,显示连接中状态 + if (isPeerConnected) { return { type: 'connected' as const, message: 'P2P连接成功', detail: '可以开始传输', }; } + if (!isWebSocketConnected) { + return { + type: 'connecting' as const, + message: '连接中', + detail: '正在建立WebSocket连接...', + }; + } + if (!isJoinedRoom) { + return { + type: 'room-ready' as const, + message: '房间已创建', + detail: '等待对方加入并建立P2P连接...', + }; + } + if (isJoinedRoom) { + return { + type: 'room-ready' as const, + message: '对方已加入房间', + detail: '正在建立P2P连接...', + }; + } + + + if (isJoinedRoom && !isPeerConnected) { + return { + type: 'room-ready' as const, + message: '房间已创建', + detail: '等待对方加入并建立P2P连接...', + }; + } + + + console.log('Unknown connection state:', connection); return { type: 'unknown' as const, message: '状态未知', @@ -134,37 +167,37 @@ const getConnectionStatusText = (connection: { isWebSocketConnected?: boolean; i const isConnecting = connection?.isConnecting || false; const error = connection?.error || null; const currentConnectType = connection?.currentConnectType || 'webrtc'; - + const wsStatus = isWebSocketConnected ? 'WS已连接' : 'WS未连接'; - const rtcStatus = isPeerConnected ? 'RTC已连接' : + const rtcStatus = isPeerConnected ? 'RTC已连接' : isWebSocketConnected ? 'RTC等待连接' : 'RTC未连接'; - + if (error) { return `${wsStatus} ${rtcStatus} - 连接失败`; } - + if (isConnecting) { return `${wsStatus} ${rtcStatus} - 连接中`; } - + if (isPeerConnected) { return `${wsStatus} ${rtcStatus} - P2P连接成功`; } - + // 如果WebSocket已连接但P2P未连接,且当前连接类型是websocket if (isWebSocketConnected && !isPeerConnected && currentConnectType === 'websocket') { return `${wsStatus} ${rtcStatus} - P2P链接失败,将使用WS进行传输`; } - + return `${wsStatus} ${rtcStatus}`; }; export function ConnectionStatus(props: ConnectionStatusProps) { const { currentRoom, className, compact = false, inline = false } = props; - + // 使用全局WebRTC状态 const webrtcState = useWebRTCStore(); - + // 创建connection对象以兼容现有代码 const connection = { isWebSocketConnected: webrtcState.isWebSocketConnected, @@ -173,14 +206,14 @@ export function ConnectionStatus(props: ConnectionStatusProps) { error: webrtcState.error, currentConnectType: webrtcState.currentConnectType, }; - + const isConnected = webrtcState.isWebSocketConnected && webrtcState.isPeerConnected; - + // 如果是内联模式,只返回状态文字 if (inline) { return {getConnectionStatusText(connection)}; } - + const status = getConnectionStatus(currentRoom ?? null); if (compact) { @@ -188,21 +221,21 @@ export function ConnectionStatus(props: ConnectionStatusProps) {
{/* 竖线分割 */}
- + {/* 连接状态指示器 */}
- WS
|
- RTC
@@ -226,9 +259,9 @@ export function ConnectionStatus(props: ConnectionStatusProps) {
WS -
- + | - +
RTC - void; } -export default function DesktopViewer({ - stream, - isConnected, - connectionCode, - onDisconnect +export default function DesktopViewer({ + stream, + isConnected, + connectionCode, + onDisconnect }: DesktopViewerProps) { const videoRef = useRef(null); const containerRef = useRef(null); const [isFullscreen, setIsFullscreen] = useState(false); - const [isMuted, setIsMuted] = useState(false); + const [isMuted, setIsMuted] = useState(true); const [showControls, setShowControls] = useState(true); const [isPlaying, setIsPlaying] = useState(false); const [needsUserInteraction, setNeedsUserInteraction] = useState(false); @@ -44,15 +44,16 @@ export default function DesktopViewer({ track.enabled = true; } }); - + videoRef.current.srcObject = stream; - console.log('[DesktopViewer] ✅ 视频元素已设置流'); - + videoRef.current.muted = true; // 确保默认静音 + console.log('[DesktopViewer] ✅ 视频元素已设置流并静音'); + // 重置状态 hasAttemptedAutoplayRef.current = false; setNeedsUserInteraction(false); setIsPlaying(false); - + // 添加事件监听器来调试视频加载 const video = videoRef.current; const handleLoadStart = () => console.log('[DesktopViewer] 📹 视频开始加载'); @@ -112,14 +113,14 @@ export default function DesktopViewer({ } }, 1000); }; - + video.addEventListener('loadstart', handleLoadStart); video.addEventListener('loadedmetadata', handleLoadedMetadata); video.addEventListener('canplay', handleCanPlay); video.addEventListener('play', handlePlay); video.addEventListener('pause', handlePause); video.addEventListener('error', handleError); - + return () => { video.removeEventListener('loadstart', handleLoadStart); video.removeEventListener('loadedmetadata', handleLoadedMetadata); @@ -168,25 +169,25 @@ export default function DesktopViewer({ const handleFullscreenChange = () => { const isCurrentlyFullscreen = !!document.fullscreenElement; setIsFullscreen(isCurrentlyFullscreen); - + if (isCurrentlyFullscreen) { // 全屏时自动隐藏控制栏,鼠标移动时显示 setShowControls(false); } else { // 退出全屏时显示控制栏 setShowControls(true); - + // 延迟检查视频状态,确保全屏切换完成 setTimeout(() => { if (videoRef.current && stream) { console.log('[DesktopViewer] 🔄 退出全屏,检查视频状态'); - + // 确保视频流正确设置 const currentSrcObject = videoRef.current.srcObject; if (!currentSrcObject || currentSrcObject !== stream) { videoRef.current.srcObject = stream; } - + // 检查视频是否暂停 if (videoRef.current.paused) { console.log('[DesktopViewer] ⏸️ 退出全屏后视频已暂停,显示播放按钮'); @@ -204,7 +205,7 @@ export default function DesktopViewer({ }; document.addEventListener('fullscreenchange', handleFullscreenChange); - + return () => { document.removeEventListener('fullscreenchange', handleFullscreenChange); }; @@ -214,12 +215,12 @@ export default function DesktopViewer({ const handleMouseMove = useCallback(() => { if (isFullscreen) { setShowControls(true); - + // 清除之前的定时器 if (hideControlsTimeoutRef.current) { clearTimeout(hideControlsTimeoutRef.current); } - + // 3秒后自动隐藏控制栏 hideControlsTimeoutRef.current = setTimeout(() => { setShowControls(false); @@ -254,7 +255,7 @@ export default function DesktopViewer({ }; document.addEventListener('keydown', handleKeyDown); - + return () => { document.removeEventListener('keydown', handleKeyDown); }; @@ -393,7 +394,7 @@ export default function DesktopViewer({ playsInline muted={isMuted} className={`w-full h-full object-contain ${isFullscreen ? 'cursor-none' : ''}`} - style={{ + style={{ aspectRatio: isFullscreen ? 'unset' : '16/9', minHeight: isFullscreen ? '100vh' : '400px' }} @@ -425,9 +426,8 @@ export default function DesktopViewer({ {/* 控制栏 */}
{/* 左侧信息 */} @@ -535,9 +535,8 @@ export default function DesktopViewer({ {/* 网络状态指示器 */}
-
+
{isConnected ? '已连接' : '连接中'}
diff --git a/chuan-next/src/components/WebRTCUnsupportedModal.tsx b/chuan-next/src/components/WebRTCUnsupportedModal.tsx index 0ebb342..e6e3a6e 100644 --- a/chuan-next/src/components/WebRTCUnsupportedModal.tsx +++ b/chuan-next/src/components/WebRTCUnsupportedModal.tsx @@ -1,6 +1,5 @@ -import React from 'react'; -import { AlertTriangle, Download, X, Chrome, Monitor } from 'lucide-react'; import { WebRTCSupport, getBrowserInfo, getRecommendedBrowsers } from '@/lib/webrtc-support'; +import { AlertTriangle, Chrome, Download, Monitor, X } from 'lucide-react'; interface Props { isOpen: boolean; @@ -22,19 +21,21 @@ export function WebRTCUnsupportedModal({ isOpen, onClose, webrtcSupport }: Props }; return ( -
-
+
+
{/* 头部 */} -
+
- -

- 浏览器不支持 WebRTC +
+ +
+

+ 浏览器兼容性提醒

@@ -43,15 +44,18 @@ export function WebRTCUnsupportedModal({ isOpen, onClose, webrtcSupport }: Props {/* 内容 */}
{/* 当前浏览器信息 */} -
-

当前浏览器状态

-
-
- 浏览器: {browserInfo.name} {browserInfo.version} +
+

+
+ 当前浏览器状态 +

+
+
+ 浏览器: {browserInfo.name} {browserInfo.version}
-
- WebRTC 支持: - +
+ WebRTC 支持: + 不支持
@@ -59,59 +63,79 @@ export function WebRTCUnsupportedModal({ isOpen, onClose, webrtcSupport }: Props
{/* 缺失的功能 */} -
-

缺失的功能:

+
+

+
+ 缺失的功能 +

{webrtcSupport.missing.map((feature, index) => ( -
-
- {feature} +
+
+ {feature}
))}
{/* 功能说明 */} -
-

为什么需要 WebRTC?

-
-
- +
+

+
+ 为什么需要 WebRTC? +

+
+
+
+ +
- 屏幕共享: 实时共享您的桌面屏幕 +
屏幕共享
+
实时共享您的桌面屏幕
-
- +
+
+ +
- 文件传输: 点对点直接传输文件,快速且安全 +
文件传输
+
点对点直接传输文件,快速且安全
-
- +
+
+ +
- 文本传输: 实时文本和图像传输 +
文本传输
+
实时文本和图像传输
{/* 浏览器推荐 */} -
-

推荐使用以下浏览器:

-
+
+

+
+ 推荐使用以下浏览器 +

+
{recommendedBrowsers.map((browser, index) => (
handleBrowserDownload(browser.downloadUrl)} >
-

{browser.name}

-

版本 {browser.minVersion}

+

{browser.name}

+

版本 {browser.minVersion}

+
+
+
-
))} @@ -120,13 +144,16 @@ export function WebRTCUnsupportedModal({ isOpen, onClose, webrtcSupport }: Props {/* 浏览器特定建议 */} {browserInfo.recommendations && ( -
-

建议

-
    +
    +

    +
    + 专属建议 +

    +
      {browserInfo.recommendations.map((recommendation, index) => ( -
    • -
      - {recommendation} +
    • +
      + {recommendation}
    • ))}
    @@ -134,31 +161,33 @@ export function WebRTCUnsupportedModal({ isOpen, onClose, webrtcSupport }: Props )} {/* 技术详情(可折叠) */} -
    - - 技术详情 +
    + + 🔧 技术详情 -
    -
    -
    - RTCPeerConnection: - - {webrtcSupport.details.rtcPeerConnection ? '支持' : '不支持'} - +
    +
    +
    +
    + RTCPeerConnection + + {webrtcSupport.details.rtcPeerConnection ? '✓ 支持' : '✗ 不支持'} + +
    -
    - DataChannel: - - {webrtcSupport.details.dataChannel ? '支持' : '不支持'} - +
    +
    + DataChannel + + {webrtcSupport.details.dataChannel ? '✓ 支持' : '✗ 不支持'} + +
    @@ -166,16 +195,16 @@ export function WebRTCUnsupportedModal({ isOpen, onClose, webrtcSupport }: Props
    {/* 底部按钮 */} -
    +
    diff --git a/chuan-next/src/components/webrtc/WebRTCDesktopSender.tsx b/chuan-next/src/components/webrtc/WebRTCDesktopSender.tsx index 1edf20a..9a16532 100644 --- a/chuan-next/src/components/webrtc/WebRTCDesktopSender.tsx +++ b/chuan-next/src/components/webrtc/WebRTCDesktopSender.tsx @@ -1,12 +1,12 @@ "use client"; -import React, { useState, useCallback, useEffect } from 'react'; +import { ConnectionStatus } from '@/components/ConnectionStatus'; +import RoomInfoDisplay from '@/components/RoomInfoDisplay'; import { Button } from '@/components/ui/button'; -import { Share, Monitor, Play, Square, Repeat } from 'lucide-react'; import { useToast } from '@/components/ui/toast-simple'; import { useDesktopShareBusiness } from '@/hooks/desktop-share'; -import RoomInfoDisplay from '@/components/RoomInfoDisplay'; -import { ConnectionStatus } from '@/components/ConnectionStatus'; +import { Monitor, Repeat, Share, Square } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; interface WebRTCDesktopSenderProps { className?: string; @@ -20,6 +20,38 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W // 使用桌面共享业务逻辑 const desktopShare = useDesktopShareBusiness(); + // 调试:监控localStream状态变化 + useEffect(() => { + console.log('[DesktopShareSender] localStream状态变化:', { + hasLocalStream: !!desktopShare.localStream, + streamId: desktopShare.localStream?.id, + trackCount: desktopShare.localStream?.getTracks().length, + isSharing: desktopShare.isSharing, + canStartSharing: desktopShare.canStartSharing, + }); + }, [desktopShare.localStream, desktopShare.isSharing, desktopShare.canStartSharing]); + + // 保持本地视频元素的引用 + const localVideoRef = useRef(null); + + // 处理本地流变化,确保视频正确显示 + useEffect(() => { + if (localVideoRef.current && desktopShare.localStream) { + console.log('[DesktopShareSender] 通过useEffect设置本地流到video元素'); + localVideoRef.current.srcObject = desktopShare.localStream; + localVideoRef.current.muted = true; + + localVideoRef.current.play().then(() => { + console.log('[DesktopShareSender] useEffect: 本地预览播放成功'); + }).catch((e: Error) => { + console.warn('[DesktopShareSender] useEffect: 本地预览播放失败:', e); + }); + } else if (localVideoRef.current && !desktopShare.localStream) { + console.log('[DesktopShareSender] 清除video元素的流'); + localVideoRef.current.srcObject = null; + } + }, [desktopShare.localStream]); + // 通知父组件连接状态变化 useEffect(() => { if (onConnectionChange && desktopShare.webRTCConnection) { @@ -27,24 +59,37 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W } }, [onConnectionChange, desktopShare.isWebSocketConnected, desktopShare.isPeerConnected, desktopShare.isConnecting]); - // 监听连接状态变化,当P2P连接断开时重置共享状态 + // 监听连接状态变化,当P2P连接断开时保持桌面共享状态 + const prevPeerConnectedRef = useRef(false); + useEffect(() => { - // 如果正在共享但P2P连接断开,自动重置共享状态 - if (desktopShare.isSharing && !desktopShare.isPeerConnected && desktopShare.connectionCode) { - console.log('[DesktopShareSender] 检测到P2P连接断开,自动重置共享状态'); - - const resetState = async () => { + // 只有从连接状态变为断开状态时才处理 + const wasPreviouslyConnected = prevPeerConnectedRef.current; + const isCurrentlyConnected = desktopShare.isPeerConnected; + + // 更新ref + prevPeerConnectedRef.current = isCurrentlyConnected; + + // 如果正在共享且从连接变为断开,保持桌面共享状态以便新用户加入 + if (desktopShare.isSharing && + wasPreviouslyConnected && + !isCurrentlyConnected && + desktopShare.connectionCode) { + + console.log('[DesktopShareSender] 检测到P2P连接断开,保持桌面共享状态等待新用户'); + + const handleDisconnect = async () => { try { - await desktopShare.resetSharing(); - console.log('[DesktopShareSender] 已自动重置共享状态'); + await desktopShare.handlePeerDisconnect(); + console.log('[DesktopShareSender] 已处理P2P断开,保持桌面共享状态'); } catch (error) { - console.error('[DesktopShareSender] 自动重置共享状态失败:', error); + console.error('[DesktopShareSender] 处理P2P断开失败:', error); } }; - - resetState(); + + handleDisconnect(); } - }, [desktopShare.isSharing, desktopShare.isPeerConnected, desktopShare.connectionCode, desktopShare.resetSharing]); + }, [desktopShare.isSharing, desktopShare.isPeerConnected, desktopShare.connectionCode]); // 移除handlePeerDisconnect依赖 // 复制房间代码 const copyCode = useCallback(async (code: string) => { @@ -57,15 +102,34 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W } }, [showToast]); - // 创建房间 + // 创建房间并开始桌面共享 + const handleCreateRoomAndStart = useCallback(async () => { + try { + setIsLoading(true); + console.log('[DesktopShareSender] 用户点击创建房间并开始共享'); + + const roomCode = await desktopShare.createRoomAndStartSharing(); + console.log('[DesktopShareSender] 房间创建并桌面共享开始成功:', roomCode); + + showToast(`房间创建成功!代码: ${roomCode},桌面共享已开始`, 'success'); + } catch (error) { + console.error('[DesktopShareSender] 创建房间并开始共享失败:', error); + const errorMessage = error instanceof Error ? error.message : '创建房间并开始共享失败'; + showToast(errorMessage, 'error'); + } finally { + setIsLoading(false); + } + }, [desktopShare, showToast]); + + // 创建房间(保留原方法) const handleCreateRoom = useCallback(async () => { try { setIsLoading(true); console.log('[DesktopShareSender] 用户点击创建房间'); - + const roomCode = await desktopShare.createRoom(); console.log('[DesktopShareSender] 房间创建成功:', roomCode); - + showToast(`房间创建成功!代码: ${roomCode}`, 'success'); } catch (error) { console.error('[DesktopShareSender] 创建房间失败:', error); @@ -81,19 +145,19 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W try { setIsLoading(true); console.log('[DesktopShareSender] 用户点击开始桌面共享'); - + await desktopShare.startSharing(); console.log('[DesktopShareSender] 桌面共享开始成功'); - + showToast('桌面共享已开始', 'success'); } catch (error) { console.error('[DesktopShareSender] 开始桌面共享失败:', error); const errorMessage = error instanceof Error ? error.message : '开始桌面共享失败'; showToast(errorMessage, 'error'); - + // 分享失败时重置状态,让用户重新选择桌面 try { - await desktopShare.resetSharing(); + // await desktopShare.resetSharing(); console.log('[DesktopShareSender] 已重置共享状态,用户可以重新选择桌面'); } catch (resetError) { console.error('[DesktopShareSender] 重置共享状态失败:', resetError); @@ -108,16 +172,16 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W try { setIsLoading(true); console.log('[DesktopShareSender] 用户点击切换桌面'); - + await desktopShare.switchDesktop(); console.log('[DesktopShareSender] 桌面切换成功'); - + showToast('桌面切换成功', 'success'); } catch (error) { console.error('[DesktopShareSender] 切换桌面失败:', error); const errorMessage = error instanceof Error ? error.message : '切换桌面失败'; showToast(errorMessage, 'error'); - + // 切换桌面失败时重置状态,让用户重新选择桌面 try { await desktopShare.resetSharing(); @@ -135,10 +199,10 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W try { setIsLoading(true); console.log('[DesktopShareSender] 用户点击停止桌面共享'); - + await desktopShare.stopSharing(); console.log('[DesktopShareSender] 桌面共享停止成功'); - + showToast('桌面共享已停止', 'success'); } catch (error) { console.error('[DesktopShareSender] 停止桌面共享失败:', error); @@ -166,8 +230,8 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W

    分享您的屏幕给其他人

    - -
    @@ -178,9 +242,9 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W

    创建桌面共享房间

    创建房间后将生成分享码,等待接收方加入后即可开始桌面共享

    - + @@ -212,8 +276,8 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W

    房间代码: {desktopShare.connectionCode}

    - -
@@ -226,70 +290,66 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W 桌面共享控制 + {/* 控制按钮 */} {desktopShare.isSharing && ( -
-
- 共享中 +
+ +
)}
- +
- {!desktopShare.isSharing ? ( -
- - - {!desktopShare.isPeerConnected && ( -
-

- 等待接收方加入房间建立P2P连接... -

-
-
- 正在等待连接 + {/* 本地预览区域(显示正在共享的内容) */} + {desktopShare.isSharing && ( +
+ {/* 共享状态指示器 */} +
+
+
+ 共享中 +
+
+ + {desktopShare.localStream ? ( +
- ) : ( -
-
- - 桌面共享进行中 -
-
- - -
-
)} +
)} diff --git a/chuan-next/src/hooks/connection/state/webConnectStore.ts b/chuan-next/src/hooks/connection/state/webConnectStore.ts index 037c223..7647d09 100644 --- a/chuan-next/src/hooks/connection/state/webConnectStore.ts +++ b/chuan-next/src/hooks/connection/state/webConnectStore.ts @@ -6,6 +6,7 @@ export interface WebConnectState { isConnecting: boolean; isWebSocketConnected: boolean; isPeerConnected: boolean; + isJoinedRoom: boolean; isDataChannelConnected: boolean; isMediaStreamConnected: boolean; currentConnectType: 'webrtc' | 'websocket'; @@ -29,6 +30,7 @@ const initialState: WebConnectState = { isConnecting: false, currentIsLocalNetWork: false, isWebSocketConnected: false, + isJoinedRoom: false, isPeerConnected: false, error: null, canRetry: false, // 初始状态下不需要重试 @@ -43,10 +45,10 @@ const initialState: WebConnectState = { export const useWebRTCStore = create((set) => ({ ...initialState, - updateState: (updates) => set((state) => ({ - ...state, - ...updates, - })), + updateState: (updates) => set((state) => { + console.log('Updating WebRTC state:', updates); + return { ...state, ...updates }; + }), setCurrentRoom: (room) => set((state) => ({ ...state, diff --git a/chuan-next/src/hooks/connection/types.ts b/chuan-next/src/hooks/connection/types.ts index f7b15ad..4a5e03d 100644 --- a/chuan-next/src/hooks/connection/types.ts +++ b/chuan-next/src/hooks/connection/types.ts @@ -111,11 +111,11 @@ export interface WebRTCTrackManager { // 设置轨道处理器 onTrack: (handler: (event: RTCTrackEvent) => void) => void; - // 创建 Offer - createOffer: (pc: RTCPeerConnection, ws: WebSocket) => Promise; + // 请求重新协商(通知 Core 层需要重新创建 Offer) + requestOfferRenegotiation: () => Promise; - // 立即创建offer(用于媒体轨道添加后的重新协商) - createOfferNow: (pc: RTCPeerConnection, ws: WebSocket) => Promise; + // 触发重新协商 + triggerRenegotiation: () => Promise; // 内部方法,供核心连接管理器调用 setPeerConnection: (pc: RTCPeerConnection | null) => void; diff --git a/chuan-next/src/hooks/connection/useConnectManager.ts b/chuan-next/src/hooks/connection/useConnectManager.ts index 0fe46bb..1349961 100644 --- a/chuan-next/src/hooks/connection/useConnectManager.ts +++ b/chuan-next/src/hooks/connection/useConnectManager.ts @@ -1,5 +1,5 @@ import { getWsUrl } from '@/lib/config'; -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useReadConnectState } from './state/useWebConnectStateManager'; import { WebConnectState } from "./state/webConnectStore"; import { ConnectType, DataHandler, IGetConnectState, IRegisterEventHandler, IWebConnection, IWebMessage, MessageHandler, Role } from "./types"; @@ -26,11 +26,20 @@ export function useConnectManager(): IWebConnection & IRegisterEventHandler & IG const wsConnection = useWebSocketConnection(); const webrtcConnection = useSharedWebRTCManagerImpl(); - // 当前活跃连接的引用 - const currentConnectionRef = useRef(wsConnection); + // 当前活跃连接的引用 - 默认使用 WebRTC + const currentConnectionRef = useRef(webrtcConnection); const { getConnectState: innerState } = useReadConnectState(); + // 确保连接引用与连接类型保持一致 + useEffect(() => { + const targetConnection = currentConnectType === 'webrtc' ? webrtcConnection : wsConnection; + if (currentConnectionRef.current !== targetConnection) { + console.log('[ConnectManager] 🔄 同步连接引用到:', currentConnectType); + currentConnectionRef.current = targetConnection; + } + }, [currentConnectType, webrtcConnection, wsConnection]); + // 连接状态管理 const connectionStateRef = useRef({ @@ -40,6 +49,7 @@ export function useConnectManager(): IWebConnection & IRegisterEventHandler & IG isPeerConnected: false, isDataChannelConnected: false, isMediaStreamConnected: false, + isJoinedRoom: false, currentConnectType: 'webrtc', state: 'closed', error: null, @@ -243,8 +253,10 @@ export function useConnectManager(): IWebConnection & IRegisterEventHandler & IG }, []); const onTrack = useCallback((callback: (event: RTCTrackEvent) => void) => { + console.log('[ConnectManager] 🎧 设置 onTrack 处理器,当前连接类型:', currentConnectType); + console.log('[ConnectManager] 当前连接引用:', currentConnectionRef.current === webrtcConnection ? 'WebRTC' : 'WebSocket'); currentConnectionRef.current.onTrack(callback); - }, []); + }, [currentConnectType, webrtcConnection]); const getPeerConnection = useCallback(() => { return currentConnectionRef.current.getPeerConnection(); diff --git a/chuan-next/src/hooks/connection/webrtc/useSharedWebRTCManager.ts b/chuan-next/src/hooks/connection/webrtc/useSharedWebRTCManager.ts index 458b262..a952735 100644 --- a/chuan-next/src/hooks/connection/webrtc/useSharedWebRTCManager.ts +++ b/chuan-next/src/hooks/connection/webrtc/useSharedWebRTCManager.ts @@ -27,9 +27,6 @@ export function useSharedWebRTCManagerImpl(): IWebConnection & IRegisterEventHan trackManager ); - // 获取当前状态 - const state = stateManager.getState(); - // 创建 createOfferNow 方法 const createOfferNow = useCallback(async () => { const pc = connectionCore.getPeerConnection(); @@ -40,7 +37,7 @@ export function useSharedWebRTCManagerImpl(): IWebConnection & IRegisterEventHan } try { - return await trackManager.createOfferNow(pc, ws); + return await connectionCore.createOfferForMedia(); } catch (error) { console.error('[SharedWebRTC] 创建 offer 失败:', error); return false; diff --git a/chuan-next/src/hooks/connection/webrtc/useWebRTCConnectionCore.ts b/chuan-next/src/hooks/connection/webrtc/useWebRTCConnectionCore.ts index 376d632..997dfa3 100644 --- a/chuan-next/src/hooks/connection/webrtc/useWebRTCConnectionCore.ts +++ b/chuan-next/src/hooks/connection/webrtc/useWebRTCConnectionCore.ts @@ -32,6 +32,9 @@ export interface WebRTCConnectionCore { // 动态注入 WebSocket 连接 injectWebSocket: (ws: WebSocket) => void; + + // 创建 Offer(供外部调用) + createOfferForMedia: () => Promise; } /** @@ -97,6 +100,68 @@ export function useWebRTCConnectionCore( isUserDisconnecting.current = false; // 重置主动断开标志 }, []); + // 创建 Offer(应该在 Core 层处理信令) + const createOffer = useCallback(async (pc: RTCPeerConnection, ws: WebSocket) => { + try { + console.log('[ConnectionCore] 🎬 开始创建offer,当前轨道数量:', pc.getSenders().length); + + // 确保连接状态稳定 + if (pc.connectionState !== 'connecting' && pc.connectionState !== 'new') { + console.warn('[ConnectionCore] ⚠️ PeerConnection状态异常:', pc.connectionState); + } + + const offer = await pc.createOffer({ + offerToReceiveAudio: true, + offerToReceiveVideo: true, + }); + + console.log('[ConnectionCore] 📝 Offer创建成功,设置本地描述...'); + await pc.setLocalDescription(offer); + console.log('[ConnectionCore] ✅ 本地描述设置完成'); + + // 等待ICE候选收集完成或超时 + await new Promise((resolve) => { + const iceTimeout = setTimeout(() => { + console.log('[ConnectionCore] ⏱️ ICE收集超时,继续发送offer'); + resolve(); + }, 3000); // 减少超时时间到3秒 + + // 如果ICE收集已经完成,立即发送 + if (pc.iceGatheringState === 'complete') { + clearTimeout(iceTimeout); + resolve(); + } else { + // 创建一个临时的监听器等待ICE收集完成 + const originalHandler = pc.onicegatheringstatechange; + pc.onicegatheringstatechange = (event) => { + console.log('[ConnectionCore] 🧊 ICE收集状态变化:', pc.iceGatheringState); + + // 调用原始处理器(如果存在) + if (originalHandler) { + originalHandler.call(pc, event); + } + + if (pc.iceGatheringState === 'complete') { + clearTimeout(iceTimeout); + // 恢复原始处理器 + pc.onicegatheringstatechange = originalHandler; + resolve(); + } + }; + } + }); + + // 发送offer + if (ws.readyState === WebSocket.OPEN && pc.localDescription) { + ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription })); + console.log('[ConnectionCore] 📤 发送 offer'); + } + } catch (error) { + console.error('[ConnectionCore] ❌ 创建 offer 失败:', error); + stateManager.updateState({ error: '创建连接失败', isConnecting: false, canRetry: true }); + } + }, [stateManager]); + // 创建 PeerConnection 和相关设置 const createPeerConnection = useCallback((ws: WebSocket, role: 'sender' | 'receiver', isReconnect: boolean = false) => { console.log('[ConnectionCore] 🔧 创建PeerConnection...', { role, isReconnect }); @@ -119,13 +184,11 @@ export function useWebRTCConnectionCore( pcRef.current = pc; // 设置轨道接收处理(对于接收方) + // 注意:这个处理器会在 TrackManager.onTrack() 中被业务逻辑覆盖 pc.ontrack = (event) => { console.log('[ConnectionCore] 🎥 PeerConnection收到轨道:', event.track.kind, event.track.id, '状态:', event.track.readyState); console.log('[ConnectionCore] 关联的流数量:', event.streams.length); - - // 这里不处理轨道,让业务逻辑的onTrack处理器处理 - // 业务逻辑会在useEffect中设置自己的处理器 - // 这样可以确保重新连接时轨道能够被正确处理 + console.log('[ConnectionCore] ⚠️ 默认轨道处理器 - 业务层应该通过 TrackManager.onTrack() 设置自己的处理器'); }; // PeerConnection 事件处理 @@ -236,9 +299,14 @@ export function useWebRTCConnectionCore( // 创建数据通道 dataChannelManager.createDataChannel(pc, role, isReconnect); + // 立即设置 TrackManager 的 PeerConnection 引用 + trackManager.setPeerConnection(pc); + trackManager.setWebSocket(ws); + console.log('[ConnectionCore] ✅ PeerConnection创建完成,角色:', role, '是否重新连接:', isReconnect); + console.log('[ConnectionCore] ✅ TrackManager 引用已设置'); return pc; - }, [stateManager, dataChannelManager]); + }, [stateManager, dataChannelManager, trackManager]); // 连接到房间 const connect = useCallback(async (roomCode: string, role: Role) => { @@ -313,7 +381,6 @@ export function useWebRTCConnectionCore( // 设置 WebSocket 消息处理 if (ws) { // 如果是外部 WebSocket,可能已经有事件处理器,我们需要保存它们 - const originalOnMessage = ws.onmessage; const originalOnError = ws.onerror; const originalOnClose = ws.onclose; @@ -332,7 +399,7 @@ export function useWebRTCConnectionCore( stateManager.updateState({ isWebSocketConnected: true, isConnected: true, - isPeerConnected: true // 标记对方已加入,可以开始P2P + isJoinedRoom: true, }); // 如果是重新连接,先清理旧的PeerConnection @@ -352,7 +419,7 @@ export function useWebRTCConnectionCore( // 发送方创建offer建立基础P2P连接 try { console.log('[ConnectionCore] 📡 创建基础P2P连接offer'); - await trackManager.createOffer(pc, ws); + await createOffer(pc, ws); } catch (error) { console.error('[ConnectionCore] 创建基础P2P连接失败:', error); } @@ -362,7 +429,7 @@ export function useWebRTCConnectionCore( stateManager.updateState({ isWebSocketConnected: true, isConnected: true, - isPeerConnected: true // 标记对方已加入 + isJoinedRoom: true, }); // 如果是重新连接,先清理旧的PeerConnection @@ -436,29 +503,23 @@ export function useWebRTCConnectionCore( if (pcAnswer) { const signalingState = pcAnswer.signalingState; - // 如果状态是stable,可能是因为之前的offer已经完成,需要重新创建offer - if (signalingState === 'stable') { - console.log('[ConnectionCore] 🔄 PeerConnection状态为stable,重新创建offer'); - try { - await trackManager.createOffer(pcAnswer, ws); - // 等待一段时间让ICE候选收集完成 - await new Promise(resolve => setTimeout(resolve, 500)); + console.log('[ConnectionCore] 当前信令状态:', signalingState, '角色:', role); - // 现在状态应该是have-local-offer,可以处理answer - if (pcAnswer.signalingState === 'have-local-offer') { - await pcAnswer.setRemoteDescription(new RTCSessionDescription(message.payload)); - console.log('[ConnectionCore] ✅ answer 处理完成'); - } else { - console.warn('[ConnectionCore] ⚠️ 重新创建offer后状态仍然不是have-local-offer:', pcAnswer.signalingState); - } + // 如果是发送方且状态是stable,说明已经有媒体轨道,应该发送新的offer而不是处理answer + if (role === 'sender' && signalingState === 'stable') { + console.log('[ConnectionCore] 🎬 发送方处于stable状态,发送包含媒体轨道的新offer'); + try { + await createOffer(pcAnswer, ws); + console.log('[ConnectionCore] ✅ 媒体offer发送完成'); } catch (error) { - console.error('[ConnectionCore] ❌ 重新创建offer失败:', error); + console.error('[ConnectionCore] ❌ 发送媒体offer失败:', error); } } else if (signalingState === 'have-local-offer') { + // 正常的answer处理 await pcAnswer.setRemoteDescription(new RTCSessionDescription(message.payload)); console.log('[ConnectionCore] ✅ answer 处理完成'); } else { - console.warn('[ConnectionCore] ⚠️ PeerConnection状态异常:', signalingState); + console.warn('[ConnectionCore] ⚠️ PeerConnection状态异常:', signalingState, '跳过answer处理'); } } } catch (error) { @@ -510,7 +571,9 @@ export function useWebRTCConnectionCore( // 对方断开连接的处理 stateManager.updateState({ isPeerConnected: false, + isDataChannelConnected: false, isConnected: false, // 添加这个状态 + isJoinedRoom: false, error: '对方已离开房间', canRetry: true }); @@ -531,7 +594,7 @@ export function useWebRTCConnectionCore( } } catch (error) { console.error('[ConnectionCore] ❌ 处理信令消息失败:', error); - stateManager.updateState({ error: '信令处理失败: ' + error, isConnecting: false, canRetry: true }); + // stateManager.updateState({ error: '信令处理失败: ' + error, isConnecting: false, canRetry: true }); } }; @@ -641,6 +704,25 @@ export function useWebRTCConnectionCore( isExternalWebSocket.current = true; }, []); + // 供外部调用的创建 Offer 方法 + const createOfferForMedia = useCallback(async () => { + const pc = pcRef.current; + const ws = wsRef.current; + + if (!pc || !ws) { + console.error('[ConnectionCore] PeerConnection 或 WebSocket 不可用'); + return false; + } + + try { + await createOffer(pc, ws); + return true; + } catch (error) { + console.error('[ConnectionCore] 创建媒体 offer 失败:', error); + return false; + } + }, [createOffer]); + return { connect, disconnect, @@ -650,5 +732,6 @@ export function useWebRTCConnectionCore( getCurrentRoom, setOnDisconnectCallback, injectWebSocket, + createOfferForMedia, }; } \ No newline at end of file diff --git a/chuan-next/src/hooks/connection/webrtc/useWebRTCTrackManager.ts b/chuan-next/src/hooks/connection/webrtc/useWebRTCTrackManager.ts index b74dab7..45fc4fa 100644 --- a/chuan-next/src/hooks/connection/webrtc/useWebRTCTrackManager.ts +++ b/chuan-next/src/hooks/connection/webrtc/useWebRTCTrackManager.ts @@ -5,77 +5,40 @@ import { WebRTCTrackManager } from '../types'; /** * WebRTC 媒体轨道管理 Hook - * 负责媒体轨道的添加和移除,处理轨道事件,提供 createOffer 功能 + * 负责媒体轨道的添加和移除,处理轨道事件 + * 信令相关功能(如 createOffer)已移至 ConnectionCore */ export function useWebRTCTrackManager( stateManager: IWebConnectStateManager ): WebRTCTrackManager { const pcRef = useRef(null); const wsRef = useRef(null); + const retryInProgressRef = useRef(false); // 防止多个重试循环 - // 创建 Offer - const createOffer = useCallback(async (pc: RTCPeerConnection, ws: WebSocket) => { - try { - console.log('[TrackManager] 🎬 开始创建offer,当前轨道数量:', pc.getSenders().length); + // 媒体协商:通知 Core 层需要重新创建 Offer + // 这个方法由业务层调用,用于添加媒体轨道后的重新协商 + const requestOfferRenegotiation = useCallback(async () => { + const pc = pcRef.current; + const ws = wsRef.current; - // 确保连接状态稳定 - if (pc.connectionState !== 'connecting' && pc.connectionState !== 'new') { - console.warn('[TrackManager] ⚠️ PeerConnection状态异常:', pc.connectionState); - } - - const offer = await pc.createOffer({ - offerToReceiveAudio: true, // 改为true以支持音频接收 - offerToReceiveVideo: true, // 改为true以支持视频接收 - }); - - console.log('[TrackManager] 📝 Offer创建成功,设置本地描述...'); - await pc.setLocalDescription(offer); - console.log('[TrackManager] ✅ 本地描述设置完成'); - - // 增加超时时间到5秒,给ICE候选收集更多时间 - const iceTimeout = setTimeout(() => { - console.log('[TrackManager] ⏱️ ICE收集超时,发送当前offer'); - if (ws.readyState === WebSocket.OPEN && pc.localDescription) { - ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription })); - console.log('[TrackManager] 📤 发送 offer (超时发送)'); - } - }, 5000); - - // 如果ICE收集已经完成,立即发送 - if (pc.iceGatheringState === 'complete') { - clearTimeout(iceTimeout); - if (ws.readyState === WebSocket.OPEN && pc.localDescription) { - ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription })); - console.log('[TrackManager] 📤 发送 offer (ICE收集完成)'); - } - } else { - console.log('[TrackManager] 🧊 等待ICE候选收集...'); - // 监听ICE收集状态变化 - pc.onicegatheringstatechange = () => { - console.log('[TrackManager] 🧊 ICE收集状态变化:', pc.iceGatheringState); - if (pc.iceGatheringState === 'complete') { - clearTimeout(iceTimeout); - if (ws.readyState === WebSocket.OPEN && pc.localDescription) { - ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription })); - console.log('[TrackManager] 📤 发送 offer (ICE收集完成)'); - } - } - }; - - // 同时监听ICE候选事件,用于调试 - pc.onicecandidate = (event) => { - if (event.candidate) { - console.log('[TrackManager] 🧊 收到ICE候选:', event.candidate.candidate.substring(0, 50) + '...'); - } else { - console.log('[TrackManager] 🏁 ICE候选收集完成'); - } - }; - } - } catch (error) { - console.error('[TrackManager] ❌ 创建 offer 失败:', error); - stateManager.updateState({ error: '创建连接失败', isConnecting: false, canRetry: true }); + if (!pc || !ws) { + console.error('[TrackManager] PeerConnection 或 WebSocket 不可用,无法请求重新协商'); + return false; } - }, [stateManager]); + + try { + console.log('[TrackManager] 📡 请求重新协商 - 媒体轨道已更新'); + // 这里应该通过回调或事件通知 Core 层重新创建 Offer + // 暂时直接调用,但更好的设计是通过事件系统 + + // 触发重新协商事件(应该由 Core 层监听) + console.log('[TrackManager] ⚠️ 需要 Core 层支持重新协商回调机制'); + return true; + } catch (error) { + console.error('[TrackManager] 请求重新协商失败:', error); + return false; + } + }, []); // 添加媒体轨道 const addTrack = useCallback((track: MediaStreamTrack, stream: MediaStream) => { @@ -113,6 +76,13 @@ export function useWebRTCTrackManager( const pc = pcRef.current; if (!pc) { console.warn('[TrackManager] PeerConnection 尚未准备就绪,将在连接建立后设置onTrack'); + + // 检查是否已有重试在进行,避免多个重试循环 + if (retryInProgressRef.current) { + console.log('[TrackManager] 已有重试进程在运行,跳过重复重试'); + return; + } + // 检查WebSocket连接状态,只有连接后才尝试设置 const state = stateManager.getState(); if (!state.isWebSocketConnected) { @@ -120,15 +90,18 @@ export function useWebRTCTrackManager( return; } + retryInProgressRef.current = true; + // 延迟设置,等待PeerConnection准备就绪 let retryCount = 0; - const maxRetries = 50; // 增加重试次数到50次,即5秒 + const maxRetries = 20; // 减少重试次数到20次,即2秒 const checkAndSetTrackHandler = () => { const currentPc = pcRef.current; if (currentPc) { console.log('[TrackManager] ✅ PeerConnection 已准备就绪,设置onTrack处理器'); currentPc.ontrack = handler; + retryInProgressRef.current = false; // 成功后重置标记 // 如果已经有远程轨道,立即触发处理 const receivers = currentPc.getReceivers(); @@ -148,6 +121,7 @@ export function useWebRTCTrackManager( setTimeout(checkAndSetTrackHandler, 100); } else { console.error('[TrackManager] ❌ PeerConnection 长时间未准备就绪,停止重试'); + retryInProgressRef.current = false; // 失败后也要重置标记 } } }; @@ -168,25 +142,34 @@ export function useWebRTCTrackManager( }); }, [stateManager]); - // 立即创建offer(用于媒体轨道添加后的重新协商) - const createOfferNow = useCallback(async (pc: RTCPeerConnection, ws: WebSocket) => { + // 立即触发重新协商(用于媒体轨道添加后的重新协商) + const triggerRenegotiation = useCallback(async () => { + const pc = pcRef.current; + const ws = wsRef.current; + if (!pc || !ws) { console.error('[TrackManager] PeerConnection 或 WebSocket 不可用'); return false; } try { - await createOffer(pc, ws); + console.log('[TrackManager] 📡 触发媒体重新协商'); + // 实际的 offer 创建应该由 Core 层处理 + // 这里只是一个触发器,通知需要重新协商 return true; } catch (error) { - console.error('[TrackManager] 创建 offer 失败:', error); + console.error('[TrackManager] 触发重新协商失败:', error); return false; } - }, [createOffer]); + }, []); // 设置 PeerConnection 引用 const setPeerConnection = useCallback((pc: RTCPeerConnection | null) => { pcRef.current = pc; + // 当PeerConnection设置时,重置重试标记 + if (pc) { + retryInProgressRef.current = false; + } }, []); // 设置 WebSocket 引用 @@ -198,8 +181,8 @@ export function useWebRTCTrackManager( addTrack, removeTrack, onTrack, - createOffer, - createOfferNow, + requestOfferRenegotiation, + triggerRenegotiation, // 内部方法,供核心连接管理器调用 setPeerConnection, setWebSocket, diff --git a/chuan-next/src/hooks/connection/ws/useWebSocketConnection.ts b/chuan-next/src/hooks/connection/ws/useWebSocketConnection.ts index f2c8dc8..f942b0e 100644 --- a/chuan-next/src/hooks/connection/ws/useWebSocketConnection.ts +++ b/chuan-next/src/hooks/connection/ws/useWebSocketConnection.ts @@ -160,6 +160,7 @@ export function useWebSocketConnection(): IWebConnection & { injectWebSocket: (w updateState({ isPeerConnected: false, isConnected: false, + isDataChannelConnected: false, error: '对方已离开房间', stateMsg: null, canRetry: true diff --git a/chuan-next/src/hooks/desktop-share/useDesktopShareBusiness.ts b/chuan-next/src/hooks/desktop-share/useDesktopShareBusiness.ts index 94c9d4c..6ffe984 100644 --- a/chuan-next/src/hooks/desktop-share/useDesktopShareBusiness.ts +++ b/chuan-next/src/hooks/desktop-share/useDesktopShareBusiness.ts @@ -6,6 +6,7 @@ interface DesktopShareState { isViewing: boolean; connectionCode: string; remoteStream: MediaStream | null; + localStream: MediaStream | null; // 添加到状态中以触发重新渲染 error: string | null; isWaitingForPeer: boolean; // 新增:是否等待对方连接 } @@ -17,6 +18,7 @@ export function useDesktopShareBusiness() { isViewing: false, connectionCode: '', remoteStream: null, + localStream: null, error: null, isWaitingForPeer: false, }); @@ -32,18 +34,19 @@ export function useDesktopShareBusiness() { // 处理远程流 const handleRemoteStream = useCallback((stream: MediaStream) => { console.log('[DesktopShare] 收到远程流:', stream.getTracks().length, '个轨道'); - updateState({ remoteStream: stream }); + setState(prev => ({ ...prev, remoteStream: stream })); // 如果有视频元素引用,设置流 if (remoteVideoRef.current) { remoteVideoRef.current.srcObject = stream; } - }, [updateState]); + }, []); // 移除updateState依赖,直接使用setState // 设置远程轨道处理器(始终监听) useEffect(() => { console.log('[DesktopShare] 🎧 设置远程轨道处理器'); - webRTC.onTrack((event: RTCTrackEvent) => { + + const trackHandler = (event: RTCTrackEvent) => { console.log('[DesktopShare] 🎥 收到远程轨道:', event.track.kind, event.track.id, '状态:', event.track.readyState); console.log('[DesktopShare] 远程流数量:', event.streams.length); @@ -62,7 +65,13 @@ export function useDesktopShareBusiness() { } }); - handleRemoteStream(remoteStream); + // 直接使用setState而不是handleRemoteStream,避免依赖问题 + setState(prev => ({ ...prev, remoteStream })); + + // 如果有视频元素引用,设置流 + if (remoteVideoRef.current) { + remoteVideoRef.current.srcObject = remoteStream; + } } else { console.warn('[DesktopShare] ⚠️ 收到轨道但没有关联的流'); // 尝试从轨道创建流 @@ -78,13 +87,21 @@ export function useDesktopShareBusiness() { } }); - handleRemoteStream(newStream); + // 直接使用setState + setState(prev => ({ ...prev, remoteStream: newStream })); + + // 如果有视频元素引用,设置流 + if (remoteVideoRef.current) { + remoteVideoRef.current.srcObject = newStream; + } } catch (error) { console.error('[DesktopShare] ❌ 从轨道创建流失败:', error); } } - }); - }, [webRTC, handleRemoteStream]); + }; + + webRTC.onTrack(trackHandler); + }, [webRTC]); // 只依赖webRTC,移除handleRemoteStream依赖 // 获取桌面共享流 const getDesktopStream = useCallback(async (): Promise => { @@ -241,10 +258,10 @@ export function useDesktopShareBusiness() { return data.code; }, []); - // 创建房间(只建立连接,等待对方加入) - const createRoom = useCallback(async (): Promise => { + // 创建房间并立即开始桌面共享 + const createRoomAndStartSharing = useCallback(async (): Promise => { try { - updateState({ error: null, isWaitingForPeer: false }); + setState(prev => ({ ...prev, error: null, isWaitingForPeer: false })); // 从后端获取房间代码 const roomCode = await createRoomFromBackend(); @@ -255,57 +272,120 @@ export function useDesktopShareBusiness() { await webRTC.connect(roomCode, 'sender'); console.log('[DesktopShare] ✅ WebSocket连接已建立'); - updateState({ + setState(prev => ({ + ...prev, + connectionCode: roomCode, + })); + + // 立即开始桌面共享(不等待P2P连接) + console.log('[DesktopShare] 📺 正在请求桌面共享权限...'); + const stream = await getDesktopStream(); + + // 停止之前的流(如果有) + if (localStreamRef.current) { + localStreamRef.current.getTracks().forEach(track => track.stop()); + } + + localStreamRef.current = stream; + console.log('[DesktopShare] ✅ 桌面流获取成功,流ID:', stream.id, '轨道数:', stream.getTracks().length); + + // 确保状态更新能正确触发重新渲染 + setState(prev => ({ + ...prev, + isSharing: true, + isWaitingForPeer: true, // 等待观看者加入 + localStream: stream, // 更新状态以触发组件重新渲染 + })); + + // 再次确认状态已更新(用于调试) + console.log('[DesktopShare] 🎉 桌面共享已开始,状态已更新,等待观看者加入'); + return roomCode; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '创建房间失败'; + console.error('[DesktopShare] ❌ 创建房间失败:', error); + setState(prev => ({ ...prev, error: errorMessage, connectionCode: '', isWaitingForPeer: false, isSharing: false })); + throw error; + } + }, [webRTC, createRoomFromBackend, getDesktopStream]); // 移除updateState依赖 + + // 创建房间(保留原有方法以兼容性) + const createRoom = useCallback(async (): Promise => { + try { + setState(prev => ({ ...prev, error: null, isWaitingForPeer: false })); + + // 从后端获取房间代码 + const roomCode = await createRoomFromBackend(); + console.log('[DesktopShare] 🚀 创建桌面共享房间,代码:', roomCode); + + // 建立WebRTC连接(作为发送方) + console.log('[DesktopShare] 📡 正在建立WebRTC连接...'); + await webRTC.connect(roomCode, 'sender'); + console.log('[DesktopShare] ✅ WebSocket连接已建立'); + + setState(prev => ({ + ...prev, connectionCode: roomCode, isWaitingForPeer: true, // 标记为等待对方连接 - }); + })); console.log('[DesktopShare] 🎯 房间创建完成,等待对方加入建立P2P连接'); return roomCode; } catch (error) { const errorMessage = error instanceof Error ? error.message : '创建房间失败'; console.error('[DesktopShare] ❌ 创建房间失败:', error); - updateState({ error: errorMessage, connectionCode: '', isWaitingForPeer: false }); + setState(prev => ({ ...prev, error: errorMessage, connectionCode: '', isWaitingForPeer: false })); throw error; } - }, [webRTC, createRoomFromBackend, updateState]); + }, [webRTC, createRoomFromBackend]); // 移除updateState依赖 - // 开始桌面共享(在接收方加入后) + // 开始桌面共享(支持有或无P2P连接状态) const startSharing = useCallback(async (): Promise => { try { - // 检查P2P连接状态(与switchDesktop保持一致) - if (!webRTC.getConnectState().isPeerConnected) { - throw new Error('P2P连接未建立'); - } - - updateState({ error: null }); + setState(prev => ({ ...prev, error: null })); console.log('[DesktopShare] 📺 正在请求桌面共享权限...'); // 获取桌面流 const stream = await getDesktopStream(); - // 停止之前的流(如果有)- 与switchDesktop保持一致 + // 停止之前的流(如果有) if (localStreamRef.current) { localStreamRef.current.getTracks().forEach(track => track.stop()); } localStreamRef.current = stream; - console.log('[DesktopShare] ✅ 桌面流获取成功'); + console.log('[DesktopShare] ✅ 桌面流获取成功,流ID:', stream.id, '轨道数:', stream.getTracks().length); - // 设置新的视频发送 - 与switchDesktop保持一致 - await setupVideoSending(stream); - console.log('[DesktopShare] ✅ 桌面共享开始完成'); - - updateState({ - isSharing: true, - isWaitingForPeer: false, - }); + // 如果P2P连接已建立,立即设置视频发送 + if (webRTC.getConnectState().isPeerConnected) { + await setupVideoSending(stream); + console.log('[DesktopShare] ✅ 桌面共享开始完成(P2P已连接)'); + setState(prev => ({ + ...prev, + isSharing: true, + isWaitingForPeer: false, + localStream: stream, + })); + } else { + // P2P连接未建立,等待观看者加入 + console.log('[DesktopShare] 📱 桌面流已准备,等待观看者加入建立P2P连接'); + setState(prev => ({ + ...prev, + isSharing: true, + isWaitingForPeer: true, + localStream: stream, + })); + } console.log('[DesktopShare] 🎉 桌面共享已开始'); } catch (error) { const errorMessage = error instanceof Error ? error.message : '开始桌面共享失败'; console.error('[DesktopShare] ❌ 开始共享失败:', error); - updateState({ error: errorMessage, isSharing: false }); + setState(prev => ({ + ...prev, + error: errorMessage, + isSharing: false, + localStream: null, + })); // 清理资源 if (localStreamRef.current) { @@ -315,20 +395,16 @@ export function useDesktopShareBusiness() { throw error; } - }, [webRTC, getDesktopStream, setupVideoSending, updateState]); + }, [webRTC, getDesktopStream]); // 移除setupVideoSending和updateState依赖 // 切换桌面共享(重新选择屏幕) const switchDesktop = useCallback(async (): Promise => { try { - if (!webRTC.getConnectState().isPeerConnected) { - throw new Error('P2P连接未建立'); - } - if (!state.isSharing) { throw new Error('当前未在共享桌面'); } - updateState({ error: null }); + setState(prev => ({ ...prev, error: null })); console.log('[DesktopShare] 🔄 正在切换桌面共享...'); // 获取新的桌面流 @@ -340,19 +416,26 @@ export function useDesktopShareBusiness() { } localStreamRef.current = newStream; - console.log('[DesktopShare] ✅ 新桌面流获取成功'); + console.log('[DesktopShare] ✅ 新桌面流获取成功,流ID:', newStream.id, '轨道数:', newStream.getTracks().length); - // 设置新的视频发送 - await setupVideoSending(newStream); - console.log('[DesktopShare] ✅ 桌面切换完成'); + // 更新状态中的本地流 + setState(prev => ({ ...prev, localStream: newStream })); + + // 如果有P2P连接,设置新的视频发送 + if (webRTC.getConnectState().isPeerConnected) { + await setupVideoSending(newStream); + console.log('[DesktopShare] ✅ 桌面切换完成(已推流给观看者)'); + } else { + console.log('[DesktopShare] ✅ 桌面切换完成(等待观看者加入)'); + } } catch (error) { const errorMessage = error instanceof Error ? error.message : '切换桌面失败'; console.error('[DesktopShare] ❌ 切换桌面失败:', error); - updateState({ error: errorMessage }); + setState(prev => ({ ...prev, error: errorMessage })); throw error; } - }, [webRTC, state.isSharing, getDesktopStream, setupVideoSending, updateState]); + }, [webRTC, state.isSharing, getDesktopStream]); // 移除setupVideoSending和updateState依赖 // 停止桌面共享 const stopSharing = useCallback(async (): Promise => { @@ -377,20 +460,22 @@ export function useDesktopShareBusiness() { // 断开WebRTC连接 webRTC.disconnect(); - updateState({ + setState(prev => ({ + ...prev, isSharing: false, connectionCode: '', error: null, isWaitingForPeer: false, - }); + localStream: null, + })); console.log('[DesktopShare] 桌面共享已停止'); } catch (error) { const errorMessage = error instanceof Error ? error.message : '停止桌面共享失败'; console.error('[DesktopShare] 停止共享失败:', error); - updateState({ error: errorMessage }); + setState(prev => ({ ...prev, error: errorMessage })); } - }, [webRTC, updateState]); + }, [webRTC]); // 移除updateState依赖,直接使用setState // 重置桌面共享到初始状态(让用户重新选择桌面) const resetSharing = useCallback(async (): Promise => { @@ -413,24 +498,85 @@ export function useDesktopShareBusiness() { } // 保留WebSocket连接和房间代码,但重置共享状态 - updateState({ + setState(prev => ({ + ...prev, isSharing: false, error: null, isWaitingForPeer: false, - }); + localStream: null, + })); console.log('[DesktopShare] 桌面共享已重置到初始状态'); } catch (error) { const errorMessage = error instanceof Error ? error.message : '重置桌面共享失败'; console.error('[DesktopShare] 重置共享失败:', error); + setState(prev => ({ ...prev, error: errorMessage })); + } + }, [webRTC]); // 移除updateState依赖 + + // 处理P2P连接断开但保持桌面共享状态(用于接收方离开房间的情况) + const handlePeerDisconnect = useCallback(async (): Promise => { + try { + console.log('[DesktopShare] P2P连接断开,但保持桌面共享状态以便新用户加入'); + + // 移除当前的发送器(清理P2P连接相关资源) + if (currentSenderRef.current) { + webRTC.removeTrack(currentSenderRef.current); + currentSenderRef.current = null; + } + + // 保持本地流和共享状态,只设置为等待新的对等方 + setState(prev => ({ + ...prev, + isWaitingForPeer: true, + error: null, + })); + + console.log('[DesktopShare] 已清理P2P连接资源,等待新用户加入'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '处理P2P断开失败'; + console.error('[DesktopShare] 处理P2P断开失败:', error); + setState(prev => ({ ...prev, error: errorMessage })); + } + }, [webRTC]); // 移除updateState依赖 + + // 重新建立P2P连接并推流(用于新用户加入房间的情况) + const resumeSharing = useCallback(async (): Promise => { + try { + console.log('[DesktopShare] 新用户加入,重新建立P2P连接并推流'); + + // 检查是否还在共享状态且有本地流 + if (!state.isSharing || !localStreamRef.current) { + console.log('[DesktopShare] 当前没有在共享或没有本地流,无法恢复推流'); + return; + } + + // 检查P2P连接状态 + if (!webRTC.getConnectState().isPeerConnected) { + console.log('[DesktopShare] P2P连接未建立,等待连接完成'); + return; + } + + // 重新设置视频发送 + await setupVideoSending(localStreamRef.current); + + updateState({ + isWaitingForPeer: false, + error: null, + }); + + console.log('[DesktopShare] ✅ P2P连接已恢复,桌面共享流已重新建立'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '恢复桌面共享失败'; + console.error('[DesktopShare] 恢复桌面共享失败:', error); updateState({ error: errorMessage }); } - }, [webRTC, updateState]); + }, [webRTC, state.isSharing]); // 移除setupVideoSending和updateState依赖 // 加入桌面共享观看 const joinSharing = useCallback(async (code: string): Promise => { try { - updateState({ error: null }); + setState(prev => ({ ...prev, error: null })); console.log('[DesktopShare] 🔍 正在加入桌面共享观看:', code); // 连接WebRTC @@ -452,7 +598,7 @@ export function useDesktopShareBusiness() { }); } - updateState({ isViewing: true }); + setState(prev => ({ ...prev, isViewing: true })); console.log('[DesktopShare] 👁️ 已进入桌面共享观看模式,等待接收流...'); // 设置一个超时检查,如果长时间没有收到流,输出警告 @@ -465,10 +611,10 @@ export function useDesktopShareBusiness() { } catch (error) { const errorMessage = error instanceof Error ? error.message : '加入桌面共享失败'; console.error('[DesktopShare] ❌ 加入观看失败:', error); - updateState({ error: errorMessage, isViewing: false }); + setState(prev => ({ ...prev, error: errorMessage, isViewing: false })); throw error; } - }, [webRTC, updateState, state.remoteStream]); + }, [webRTC, state.remoteStream]); // 移除updateState依赖 // 停止观看桌面共享 const stopViewing = useCallback(async (): Promise => { @@ -478,19 +624,20 @@ export function useDesktopShareBusiness() { // 断开WebRTC连接 webRTC.disconnect(); - updateState({ + setState(prev => ({ + ...prev, isViewing: false, remoteStream: null, error: null, - }); + })); console.log('[DesktopShare] 已停止观看桌面共享'); } catch (error) { const errorMessage = error instanceof Error ? error.message : '停止观看失败'; console.error('[DesktopShare] 停止观看失败:', error); - updateState({ error: errorMessage }); + setState(prev => ({ ...prev, error: errorMessage })); } - }, [webRTC, updateState]); + }, [webRTC]); // 移除updateState依赖 // 设置远程视频元素引用 const setRemoteVideoRef = useCallback((videoElement: HTMLVideoElement | null) => { @@ -500,6 +647,55 @@ export function useDesktopShareBusiness() { } }, [state.remoteStream]); + // 监听P2P连接状态变化,自动处理重新连接 + const prevPeerConnectedForResumeRef = useRef(false); + + useEffect(() => { + const isPeerConnected = webRTC.getConnectState().isPeerConnected; + const wasPreviouslyDisconnected = !prevPeerConnectedForResumeRef.current; + + // 更新ref + prevPeerConnectedForResumeRef.current = isPeerConnected; + + // 当P2P连接从断开变为连接且正在等待对方时,自动恢复推流 + if (isPeerConnected && + wasPreviouslyDisconnected && + state.isWaitingForPeer && + state.isSharing) { + console.log('[DesktopShare] 🔄 P2P连接已建立,自动恢复桌面共享推流'); + + // 调用resumeSharing但不依赖它 + const handleResume = async () => { + try { + console.log('[DesktopShare] 新用户加入,重新建立P2P连接并推流'); + + // 检查是否还在共享状态且有本地流 + if (!state.isSharing || !localStreamRef.current) { + console.log('[DesktopShare] 当前没有在共享或没有本地流,无法恢复推流'); + return; + } + + // 重新设置视频发送 + await setupVideoSending(localStreamRef.current); + + setState(prev => ({ + ...prev, + isWaitingForPeer: false, + error: null, + })); + + console.log('[DesktopShare] ✅ P2P连接已恢复,桌面共享流已重新建立'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '恢复桌面共享失败'; + console.error('[DesktopShare] 恢复桌面共享失败:', error); + setState(prev => ({ ...prev, error: errorMessage })); + } + }; + + handleResume(); + } + }, [webRTC.getConnectState().isPeerConnected, state.isWaitingForPeer, state.isSharing]); // 移除resumeSharing依赖 + // 清理资源 useEffect(() => { return () => { @@ -515,6 +711,7 @@ export function useDesktopShareBusiness() { isViewing: state.isViewing, connectionCode: state.connectionCode, remoteStream: state.remoteStream, + localStream: state.localStream, // 使用状态中的流 error: state.error, isWaitingForPeer: state.isWaitingForPeer, isConnected: webRTC.getConnectState().isConnected, @@ -526,10 +723,13 @@ export function useDesktopShareBusiness() { // 方法 createRoom, // 创建房间 + createRoomAndStartSharing, // 创建房间并立即开始桌面共享 startSharing, // 选择桌面并建立P2P连接 switchDesktop, // 新增:切换桌面 stopSharing, resetSharing, // 重置到初始状态,保留房间连接 + handlePeerDisconnect, // 处理P2P断开但保持桌面共享 + resumeSharing, // 重新建立P2P连接并推流 joinSharing, stopViewing, setRemoteVideoRef, diff --git a/chuan-next/src/lib/config.ts b/chuan-next/src/lib/config.ts index 10d1fdb..81b94dc 100644 --- a/chuan-next/src/lib/config.ts +++ b/chuan-next/src/lib/config.ts @@ -26,16 +26,17 @@ const getCurrentBaseUrl = () => { // 动态获取 WebSocket URL - 总是在客户端运行时计算 const getCurrentWsUrl = () => { + return "ws://192.168.1.120:8080" if (typeof window !== 'undefined') { // 检查是否是 Next.js 开发服务器(端口 3000 或 3001) - const isNextDevServer = window.location.hostname === 'localhost' && - (window.location.port === '3000' || window.location.port === '3001'); - + const isNextDevServer = window.location.hostname === 'localhost' && + (window.location.port === '3000' || window.location.port === '3001'); + if (isNextDevServer) { // 开发模式:通过 Next.js 开发服务器访问,连接到后端 WebSocket return 'ws://localhost:8080'; } - + // 生产模式或通过 Go 服务器访问:使用当前域名和端口 const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${protocol}//${window.location.host}`; @@ -49,28 +50,28 @@ export const config = { isDev: getEnv('NODE_ENV') === 'development', isProd: getEnv('NODE_ENV') === 'production', isStatic: typeof window !== 'undefined', // 客户端运行时认为是静态模式 - + // API配置 api: { // 后端API地址 (服务器端使用) backendUrl: getEnv('GO_BACKEND_URL', 'http://localhost:8080'), - + // 前端API基础URL (客户端使用) - 开发模式下调用 Next.js API 路由 baseUrl: getEnv('NEXT_PUBLIC_API_BASE_URL', 'http://localhost:3000'), - + // 直接后端URL (客户端在静态模式下使用) - 如果环境变量为空,则使用当前域名 directBackendUrl: getEnv('NEXT_PUBLIC_BACKEND_URL') || getCurrentBaseUrl(), - + // WebSocket地址 - 在客户端运行时动态计算,不在构建时预设 wsUrl: '', // 将通过 getWsUrl() 函数动态获取 }, - + // 超时配置 timeout: { api: 30000, // 30秒 ws: 60000, // 60秒 }, - + // 重试配置 retry: { max: 3, @@ -122,12 +123,12 @@ export function getWsUrl(): string { if (envWsUrl) { return envWsUrl; } - + // 如果是服务器端(SSG构建时),返回空字符串 if (typeof window === 'undefined') { return ''; } - + // 客户端运行时动态计算 return getCurrentWsUrl(); } diff --git a/cmd/config.go b/cmd/config.go index 1d06858..9671343 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -14,6 +14,16 @@ import ( type Config struct { Port int FrontendDir string + TurnConfig TurnConfig +} + +// TurnConfig TURN服务器配置 +type TurnConfig struct { + Enabled bool `json:"enabled"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + Realm string `json:"realm"` } // loadEnvFile 加载环境变量文件 @@ -64,6 +74,11 @@ func showHelp() { fmt.Println(" 环境变量:") fmt.Println(" PORT=8080 - 服务器监听端口") fmt.Println(" FRONTEND_DIR=/path - 外部前端文件目录 (可选)") + fmt.Println(" TURN_ENABLED=true - 启用TURN服务器") + fmt.Println(" TURN_PORT=3478 - TURN服务器端口") + fmt.Println(" TURN_USERNAME=user - TURN服务器用户名") + fmt.Println(" TURN_PASSWORD=pass - TURN服务器密码") + fmt.Println(" TURN_REALM=localhost - TURN服务器域") fmt.Println(" 命令行参数:") flag.PrintDefaults() fmt.Println("") @@ -73,6 +88,7 @@ func showHelp() { fmt.Println(" ./file-transfer-server") fmt.Println(" ./file-transfer-server -port 3000") fmt.Println(" PORT=8080 FRONTEND_DIR=./dist ./file-transfer-server") + fmt.Println(" TURN_ENABLED=true TURN_PORT=3478 ./file-transfer-server") } // loadConfig 加载应用配置 @@ -90,6 +106,27 @@ func loadConfig() *Config { } } + // TURN 配置默认值 + turnEnabled := os.Getenv("TURN_ENABLED") == "true" + turnPort := 3478 + if envTurnPort := os.Getenv("TURN_PORT"); envTurnPort != "" { + if port, err := strconv.Atoi(envTurnPort); err == nil { + turnPort = port + } + } + turnUsername := os.Getenv("TURN_USERNAME") + if turnUsername == "" { + turnUsername = "chuan" + } + turnPassword := os.Getenv("TURN_PASSWORD") + if turnPassword == "" { + turnPassword = "chuan123" + } + turnRealm := os.Getenv("TURN_REALM") + if turnRealm == "" { + turnRealm = "localhost" + } + // 定义命令行参数 var port = flag.Int("port", defaultPort, "服务器监听端口 (可通过 PORT 环境变量设置)") var help = flag.Bool("help", false, "显示帮助信息") @@ -104,6 +141,13 @@ func loadConfig() *Config { config := &Config{ Port: *port, FrontendDir: os.Getenv("FRONTEND_DIR"), + TurnConfig: TurnConfig{ + Enabled: turnEnabled, + Port: turnPort, + Username: turnUsername, + Password: turnPassword, + Realm: turnRealm, + }, } return config @@ -121,4 +165,14 @@ func logConfig(config *Config) { } else { log.Printf("📦 使用内嵌前端文件") } + + // 记录 TURN 配置信息 + if config.TurnConfig.Enabled { + log.Printf("🔄 TURN服务器已启用") + log.Printf(" 端口: %d", config.TurnConfig.Port) + log.Printf(" 用户名: %s", config.TurnConfig.Username) + log.Printf(" 域: %s", config.TurnConfig.Realm) + } else { + log.Printf("❌ TURN服务器已禁用") + } } diff --git a/cmd/main.go b/cmd/main.go index 421af20..e51b7bd 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -18,8 +18,8 @@ func main() { logConfig(config) // 设置路由 - router := setupRouter() + routerSetup := setupRouter(config) // 运行服务器(包含启动和优雅关闭) - RunServer(config, router) + RunServer(config, routerSetup) } diff --git a/cmd/router.go b/cmd/router.go index 82d2e29..125cc00 100644 --- a/cmd/router.go +++ b/cmd/router.go @@ -11,8 +11,14 @@ import ( "github.com/go-chi/cors" ) +// RouterSetup 路由设置结果 +type RouterSetup struct { + Handler *handlers.Handler + Router http.Handler +} + // setupRouter 设置路由和中间件 -func setupRouter() http.Handler { +func setupRouter(config *Config) *RouterSetup { // 初始化处理器 h := handlers.NewHandler() @@ -22,12 +28,15 @@ func setupRouter() http.Handler { setupMiddleware(router) // 设置API路由 - setupAPIRoutes(router, h) + setupAPIRoutes(router, h, config) // 设置前端路由 router.Handle("/*", web.CreateFrontendHandler()) - return router + return &RouterSetup{ + Handler: h, + Router: router, + } } // setupMiddleware 设置中间件 @@ -48,11 +57,20 @@ func setupMiddleware(r *chi.Mux) { } // setupAPIRoutes 设置API路由 -func setupAPIRoutes(r *chi.Mux, h *handlers.Handler) { +func setupAPIRoutes(r *chi.Mux, h *handlers.Handler, config *Config) { // WebRTC信令WebSocket路由 r.Get("/api/ws/webrtc", h.HandleWebRTCWebSocket) // WebRTC房间API r.Post("/api/create-room", h.CreateRoomHandler) r.Get("/api/room-info", h.WebRTCRoomStatusHandler) + + // TURN服务器API(仅在启用时可用) + if config.TurnConfig.Enabled { + r.Get("/api/turn/stats", h.TurnStatsHandler) + r.Get("/api/turn/config", h.TurnConfigHandler) + } + + // 管理API + r.Get("/api/admin/status", h.AdminStatusHandler) } diff --git a/cmd/server.go b/cmd/server.go index 409ef13..8f9a4ab 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -9,30 +9,56 @@ import ( "os/signal" "syscall" "time" + + "chuan/internal/services" ) // Server 服务器结构 type Server struct { - httpServer *http.Server - config *Config + httpServer *http.Server + config *Config + turnService *services.TurnService } // NewServer 创建新的服务器实例 -func NewServer(config *Config, handler http.Handler) *Server { - return &Server{ +func NewServer(config *Config, routerSetup *RouterSetup) *Server { + server := &Server{ httpServer: &http.Server{ Addr: fmt.Sprintf(":%d", config.Port), - Handler: handler, + Handler: routerSetup.Router, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, }, config: config, } + + // 如果启用了TURN服务器,创建TURN服务实例 + if config.TurnConfig.Enabled { + turnConfig := services.TurnServiceConfig{ + Port: config.TurnConfig.Port, + Username: config.TurnConfig.Username, + Password: config.TurnConfig.Password, + Realm: config.TurnConfig.Realm, + } + server.turnService = services.NewTurnService(turnConfig) + + // 将TURN服务设置到处理器中 + routerSetup.Handler.SetTurnService(server.turnService) + } + + return server } // Start 启动服务器 func (s *Server) Start() error { + // 启动TURN服务器(如果启用) + if s.turnService != nil { + if err := s.turnService.Start(); err != nil { + return fmt.Errorf("启动TURN服务器失败: %v", err) + } + } + log.Printf("🚀 服务器启动在端口 :%d", s.config.Port) return s.httpServer.ListenAndServe() } @@ -40,6 +66,14 @@ func (s *Server) Start() error { // Stop 停止服务器 func (s *Server) Stop(ctx context.Context) error { log.Println("🛑 正在关闭服务器...") + + // 停止TURN服务器(如果启用) + if s.turnService != nil { + if err := s.turnService.Stop(); err != nil { + log.Printf("⚠️ 停止TURN服务器失败: %v", err) + } + } + return s.httpServer.Shutdown(ctx) } @@ -62,8 +96,8 @@ func (s *Server) WaitForShutdown() { } // RunServer 运行服务器(包含启动和优雅关闭) -func RunServer(config *Config, handler http.Handler) { - server := NewServer(config, handler) +func RunServer(config *Config, routerSetup *RouterSetup) { + server := NewServer(config, routerSetup) // 启动服务器 go func() { diff --git a/go.mod b/go.mod index f11aae2..7335fd4 100644 --- a/go.mod +++ b/go.mod @@ -7,3 +7,15 @@ require ( github.com/go-chi/cors v1.2.1 github.com/gorilla/websocket v1.5.3 ) + +require ( + github.com/pion/dtls/v2 v2.2.7 // indirect + github.com/pion/logging v0.2.2 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/stun/v2 v2.0.0 // indirect + github.com/pion/transport/v2 v2.2.1 // indirect + github.com/pion/transport/v3 v3.0.2 // indirect + github.com/pion/turn/v3 v3.0.3 // indirect + golang.org/x/crypto v0.21.0 // indirect + golang.org/x/sys v0.18.0 // indirect +) diff --git a/go.sum b/go.sum index a07d7b1..f6126f5 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,90 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk= github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8= +github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= +github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= +github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0= +github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ= +github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c= +github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= +github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= +github.com/pion/transport/v3 v3.0.2 h1:r+40RJR25S9w3jbA6/5uEPTzcdn7ncyU44RWCbHkLg4= +github.com/pion/transport/v3 v3.0.2/go.mod h1:nIToODoOlb5If2jF9y2Igfx3PFYWfuXi37m0IlWa/D0= +github.com/pion/turn/v3 v3.0.3 h1:1e3GVk8gHZLPBA5LqadWYV60lmaKUaHCkm9DX9CkGcE= +github.com/pion/turn/v3 v3.0.3/go.mod h1:vw0Dz420q7VYAF3J4wJKzReLHIo2LGp4ev8nXQexYsc= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index b2b8faa..f110b6f 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -10,6 +10,7 @@ import ( type Handler struct { webrtcService *services.WebRTCService + turnService *services.TurnService } func NewHandler() *Handler { @@ -18,6 +19,11 @@ func NewHandler() *Handler { } } +// SetTurnService 设置TURN服务实例 +func (h *Handler) SetTurnService(turnService *services.TurnService) { + h.turnService = turnService +} + // HandleWebRTCWebSocket 处理WebRTC信令WebSocket连接 func (h *Handler) HandleWebRTCWebSocket(w http.ResponseWriter, r *http.Request) { h.webrtcService.HandleWebSocket(w, r) @@ -105,3 +111,101 @@ func (h *Handler) GetRoomStatusHandler(w http.ResponseWriter, r *http.Request) { status := h.webrtcService.GetRoomStatus(code) json.NewEncoder(w).Encode(status) } + +// TurnStatsHandler 获取TURN服务器统计信息API +func (h *Handler) TurnStatsHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.Method != http.MethodGet { + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": "方法不允许", + }) + return + } + + if h.turnService == nil { + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": "TURN服务器未启用", + }) + return + } + + stats := h.turnService.GetStats() + response := map[string]interface{}{ + "success": true, + "data": stats, + } + + json.NewEncoder(w).Encode(response) +} + +// TurnConfigHandler 获取TURN服务器配置信息API(用于前端WebRTC配置) +func (h *Handler) TurnConfigHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.Method != http.MethodGet { + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": "方法不允许", + }) + return + } + + if h.turnService == nil || !h.turnService.IsRunning() { + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": "TURN服务器未启用或未运行", + }) + return + } + + turnInfo := h.turnService.GetTurnServerInfo() + response := map[string]interface{}{ + "success": true, + "data": turnInfo, + } + + json.NewEncoder(w).Encode(response) +} + +// AdminStatusHandler 获取服务器总体状态API +func (h *Handler) AdminStatusHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.Method != http.MethodGet { + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": "方法不允许", + }) + return + } + + // 获取WebRTC服务状态 + // 这里简化,实际可以从WebRTC服务获取更多信息 + webrtcStatus := map[string]interface{}{ + "isRunning": true, // WebRTC服务总是运行的 + } + + // 获取TURN服务状态 + var turnStatus interface{} + if h.turnService != nil { + turnStatus = h.turnService.GetStats() + } else { + turnStatus = map[string]interface{}{ + "isRunning": false, + "message": "TURN服务器未启用", + } + } + + response := map[string]interface{}{ + "success": true, + "data": map[string]interface{}{ + "webrtc": webrtcStatus, + "turn": turnStatus, + }, + } + + json.NewEncoder(w).Encode(response) +} diff --git a/internal/services/turn_service.go b/internal/services/turn_service.go new file mode 100644 index 0000000..86f82ff --- /dev/null +++ b/internal/services/turn_service.go @@ -0,0 +1,234 @@ +package services + +import ( + "fmt" + "log" + "net" + "sync" + + "github.com/pion/turn/v3" +) + +// TurnService TURN服务器结构 +type TurnService struct { + server *turn.Server + config TurnServiceConfig + stats *TurnStats + isRunning bool + mu sync.RWMutex +} + +// TurnServiceConfig TURN服务器配置 +type TurnServiceConfig struct { + Port int + Username string + Password string + Realm string +} + +// TurnStats TURN服务器统计信息 +type TurnStats struct { + ActiveAllocations int64 + TotalAllocations int64 + BytesTransferred int64 + PacketsTransferred int64 + Connections int64 + mu sync.RWMutex +} + +// NewTurnService 创建新的TURN服务实例 +func NewTurnService(config TurnServiceConfig) *TurnService { + return &TurnService{ + config: config, + stats: &TurnStats{}, + } +} + +// Start 启动TURN服务器 +func (ts *TurnService) Start() error { + ts.mu.Lock() + defer ts.mu.Unlock() + + if ts.isRunning { + return fmt.Errorf("TURN服务器已在运行") + } + + // 监听UDP端口 + udpListener, err := net.ListenPacket("udp4", fmt.Sprintf("0.0.0.0:%d", ts.config.Port)) + if err != nil { + return fmt.Errorf("无法监听UDP端口: %v", err) + } + + // 监听TCP端口 + tcpListener, err := net.Listen("tcp4", fmt.Sprintf("0.0.0.0:%d", ts.config.Port)) + if err != nil { + udpListener.Close() + return fmt.Errorf("无法监听TCP端口: %v", err) + } + + // 创建TURN服务器配置 + turnConfig := turn.ServerConfig{ + Realm: ts.config.Realm, + AuthHandler: ts.authHandler, + PacketConnConfigs: []turn.PacketConnConfig{ + { + PacketConn: udpListener, + RelayAddressGenerator: &turn.RelayAddressGeneratorStatic{ + RelayAddress: net.ParseIP("127.0.0.1"), // 在生产环境中应该使用公网IP + Address: "0.0.0.0", + }, + }, + }, + ListenerConfigs: []turn.ListenerConfig{ + { + Listener: tcpListener, + RelayAddressGenerator: &turn.RelayAddressGeneratorStatic{ + RelayAddress: net.ParseIP("127.0.0.1"), // 在生产环境中应该使用公网IP + Address: "0.0.0.0", + }, + }, + }, + } + + // 创建TURN服务器 + server, err := turn.NewServer(turnConfig) + if err != nil { + udpListener.Close() + tcpListener.Close() + return fmt.Errorf("创建TURN服务器失败: %v", err) + } + + ts.server = server + ts.isRunning = true + + log.Printf("🔄 TURN服务器启动成功,监听端口: %d", ts.config.Port) + log.Printf(" 用户名: %s, 域: %s", ts.config.Username, ts.config.Realm) + + return nil +} + +// Stop 停止TURN服务器 +func (ts *TurnService) Stop() error { + ts.mu.Lock() + defer ts.mu.Unlock() + + if !ts.isRunning { + return fmt.Errorf("TURN服务器未运行") + } + + if ts.server != nil { + if err := ts.server.Close(); err != nil { + return fmt.Errorf("关闭TURN服务器失败: %v", err) + } + } + + ts.isRunning = false + log.Printf("🛑 TURN服务器已停止") + + return nil +} + +// IsRunning 检查TURN服务器是否正在运行 +func (ts *TurnService) IsRunning() bool { + ts.mu.RLock() + defer ts.mu.RUnlock() + return ts.isRunning +} + +// authHandler 认证处理器 +func (ts *TurnService) authHandler(username string, realm string, srcAddr net.Addr) ([]byte, bool) { + // 记录连接统计 + ts.stats.mu.Lock() + ts.stats.Connections++ + ts.stats.mu.Unlock() + + log.Printf("🔐 TURN认证请求: 用户=%s, 域=%s, 地址=%s", username, realm, srcAddr.String()) + + // 简单的用户名密码验证 + if username == ts.config.Username && realm == ts.config.Realm { + // 记录分配统计 + ts.stats.mu.Lock() + ts.stats.ActiveAllocations++ + ts.stats.TotalAllocations++ + ts.stats.mu.Unlock() + + log.Printf("📊 TURN认证成功: 活跃分配=%d, 总分配=%d", ts.stats.ActiveAllocations, ts.stats.TotalAllocations) + + // 返回密码的key + return turn.GenerateAuthKey(username, ts.config.Realm, ts.config.Password), true + } + + log.Printf("❌ TURN认证失败: 用户=%s", username) + return nil, false +} + +// GetStats 获取统计信息 +func (ts *TurnService) GetStats() TurnStatsResponse { + ts.stats.mu.RLock() + defer ts.stats.mu.RUnlock() + + return TurnStatsResponse{ + IsRunning: ts.IsRunning(), + ActiveAllocations: ts.stats.ActiveAllocations, + TotalAllocations: ts.stats.TotalAllocations, + BytesTransferred: ts.stats.BytesTransferred, + PacketsTransferred: ts.stats.PacketsTransferred, + Connections: ts.stats.Connections, + Port: ts.config.Port, + Username: ts.config.Username, + Realm: ts.config.Realm, + } +} + +// GetTurnServerInfo 获取TURN服务器信息用于客户端 +func (ts *TurnService) GetTurnServerInfo() TurnServerInfo { + if !ts.IsRunning() { + return TurnServerInfo{} + } + + return TurnServerInfo{ + URLs: []string{fmt.Sprintf("turn:localhost:%d", ts.config.Port)}, + Username: ts.config.Username, + Credential: ts.config.Password, + } +} + +// UpdateStats 更新传输统计 (可以从外部调用) +func (ts *TurnService) UpdateStats(bytes, packets int64) { + ts.stats.mu.Lock() + defer ts.stats.mu.Unlock() + + ts.stats.BytesTransferred += bytes + ts.stats.PacketsTransferred += packets +} + +// DecrementActiveAllocations 减少活跃分配数(当连接关闭时调用) +func (ts *TurnService) DecrementActiveAllocations() { + ts.stats.mu.Lock() + defer ts.stats.mu.Unlock() + + if ts.stats.ActiveAllocations > 0 { + ts.stats.ActiveAllocations-- + log.Printf("📊 TURN分配释放: 活跃分配=%d", ts.stats.ActiveAllocations) + } +} + +// TurnStatsResponse TURN统计响应结构 +type TurnStatsResponse struct { + IsRunning bool `json:"isRunning"` + ActiveAllocations int64 `json:"activeAllocations"` + TotalAllocations int64 `json:"totalAllocations"` + BytesTransferred int64 `json:"bytesTransferred"` + PacketsTransferred int64 `json:"packetsTransferred"` + Connections int64 `json:"connections"` + Port int `json:"port"` + Username string `json:"username"` + Realm string `json:"realm"` +} + +// TurnServerInfo TURN服务器信息结构 (用于WebRTC配置) +type TurnServerInfo struct { + URLs []string `json:"urls"` + Username string `json:"username"` + Credential string `json:"credential"` +} \ No newline at end of file