From 7777566e7a67f1d8031f422f9457b6f7fa8769bd Mon Sep 17 00:00:00 2001 From: celebrateyang Date: Thu, 5 Jun 2025 22:57:34 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=87=E4=BB=B6=E4=BC=A0=E8=BE=9311-?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8A=A0=E5=85=A5=E4=BC=9A=E8=AF=9D=E4=B8=8D?= =?UTF-8?q?=E7=A8=B3=E5=AE=9A=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/clipboard/TextSharing.svelte | 407 +++++++++++++++--- web/src/lib/clipboard/clipboard-manager.ts | 266 ++++++++++-- web/src/routes/clipboard/+page.svelte | 280 ++++++++++-- 3 files changed, 841 insertions(+), 112 deletions(-) diff --git a/web/src/components/clipboard/TextSharing.svelte b/web/src/components/clipboard/TextSharing.svelte index 776a5ce8..ff1633e1 100644 --- a/web/src/components/clipboard/TextSharing.svelte +++ b/web/src/components/clipboard/TextSharing.svelte @@ -1,5 +1,5 @@ + +{#if showNewMessageNotification} +
+
+
📩
+
+ 收到新消息 + 点击此处查看 +
+ +
+
+{/if} +
-
-

发送文本

- - 发送文本 - -
- -
-

已接收文本

+ +
+

已接收文本 {#if isNewMessage}{/if}

{#if receivedText}
{receivedText}
@@ -71,10 +143,128 @@
{/if}
+ + +
+

发送文本

+ + + 发送文本 + +
diff --git a/web/src/lib/clipboard/clipboard-manager.ts b/web/src/lib/clipboard/clipboard-manager.ts index f4ac519d..7d4ab8df 100644 --- a/web/src/lib/clipboard/clipboard-manager.ts +++ b/web/src/lib/clipboard/clipboard-manager.ts @@ -41,7 +41,10 @@ export const clipboardState = writable({ receivingFiles: false, transferProgress: 0, dataChannel: null as RTCDataChannel | null, - peerConnection: null as RTCPeerConnection | null + peerConnection: null as RTCPeerConnection | null, + errorMessage: '' as string, + showError: false as boolean, + waitingForCreator: false as boolean }); export class ClipboardManager { @@ -92,19 +95,31 @@ export class ClipboardManager { if (typeof window !== 'undefined') { localStorage.removeItem('clipboard_session'); } - } - - private startStatusCheck(): void { + } private startStatusCheck(): void { + // 在移动端使用更频繁的状态检查以确保UI及时更新 + const isMobile = typeof window !== 'undefined' && + (/Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)); + const checkInterval = isMobile ? 300 : 1000; // 移动端300ms,桌面端1秒 + this.statusInterval = setInterval(() => { const wsConnected = this.ws?.readyState === WebSocket.OPEN; const peerConnected = this.dataChannel?.readyState === 'open'; - clipboardState.update(state => ({ - ...state, - isConnected: wsConnected, - peerConnected: peerConnected - })); - }, 1000); + // 获取当前状态避免不必要的更新 + let currentState: any = {}; + const unsubscribe = clipboardState.subscribe(s => currentState = s); + unsubscribe(); + + // 只在状态真正变化时更新 + if (currentState.isConnected !== wsConnected || currentState.peerConnected !== peerConnected) { + console.log('📱 Status update:', { wsConnected, peerConnected, isMobile }); + clipboardState.update(state => ({ + ...state, + isConnected: wsConnected, + peerConnected: peerConnected + })); + } + }, checkInterval); } // WebSocket management @@ -353,9 +368,7 @@ export class ClipboardManager { const url = `${origin}/clipboard?session=${sessionId}`; navigator.clipboard.writeText(url); } - } - - cleanup(): void { + } cleanup(): void { if (this.dataChannel) { this.dataChannel.close(); this.dataChannel = null; @@ -377,7 +390,10 @@ export class ClipboardManager { sessionId: '', isConnected: false, peerConnected: false, - qrCodeUrl: '' + qrCodeUrl: '', + errorMessage: '', + showError: false, + waitingForCreator: false })); this.sharedKey = null; @@ -385,7 +401,15 @@ export class ClipboardManager { this.clearStoredSession(); } - // WebSocket message handler + // 清除错误消息 + clearError(): void { + clipboardState.update(state => ({ + ...state, + errorMessage: '', + showError: false, + waitingForCreator: false + })); + }// WebSocket message handler private async handleWebSocketMessage(message: any): Promise { console.log('Handling WebSocket message:', message.type); @@ -395,7 +419,9 @@ export class ClipboardManager { ...state, sessionId: message.sessionId, isCreating: false, - isCreator: true + isCreator: true, + errorMessage: '', + showError: false })); await this.generateQRCode(message.sessionId); this.saveSession(message.sessionId, true); @@ -405,7 +431,13 @@ export class ClipboardManager { console.log('Session joined successfully, setting up WebRTC...'); await this.importRemotePublicKey(message.publicKey); await this.deriveSharedKey(); - clipboardState.update(state => ({ ...state, isJoining: false })); + clipboardState.update(state => ({ + ...state, + isJoining: false, + waitingForCreator: false, + errorMessage: '', + showError: false + })); await this.setupWebRTC(false); break; @@ -426,6 +458,36 @@ export class ClipboardManager { } break; + case 'waiting_for_creator': + console.log('Waiting for creator to reconnect...'); + clipboardState.update(state => ({ + ...state, + isJoining: false, + waitingForCreator: true, + errorMessage: message.message || '等待创建者重新连接', + showError: true + })); + break; + + case 'peer_disconnected': + console.log('Peer disconnected'); + clipboardState.update(state => ({ + ...state, + peerConnected: false, + errorMessage: '对端已断开连接', + showError: true + })); + // 清理 WebRTC 连接但保持 WebSocket 连接 + if (this.peerConnection) { + this.peerConnection.close(); + this.peerConnection = null; + } + if (this.dataChannel) { + this.dataChannel.close(); + this.dataChannel = null; + } + break; + case 'offer': await this.handleOffer(message.offer); break; @@ -440,20 +502,48 @@ export class ClipboardManager { case 'error': console.error('Server error:', message); + let errorMessage = '连接错误'; + + // 处理特定的错误消息 + if (message.message) { + switch (message.message) { + case '会话不存在或已过期': + errorMessage = '会话已过期或不存在,请创建新会话'; + this.clearStoredSession(); // 清理本地存储的过期会话 + break; + case '会话已满': + errorMessage = '会话已满,无法加入'; + break; + case '未知消息类型': + errorMessage = '通信协议错误'; + break; + case '消息格式错误': + errorMessage = '数据格式错误'; + break; + default: + errorMessage = message.message; + } + } + clipboardState.update(state => ({ ...state, isCreating: false, - isJoining: false + isJoining: false, + waitingForCreator: false, + errorMessage, + showError: true })); break; } - } - - // WebRTC setup and handlers + } // WebRTC setup and handlers private async setupWebRTC(isInitiator: boolean): Promise { try { this.peerConnection = new RTCPeerConnection({ - iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] + iceServers: [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' } // 添加备用STUN服务器 + ], + iceCandidatePoolSize: 10 // 增加ICE候选池大小 }); // Update state with peer connection @@ -462,37 +552,84 @@ export class ClipboardManager { peerConnection: this.peerConnection })); + // 添加ICE连接状态监听 + this.peerConnection.oniceconnectionstatechange = () => { + const iceState = this.peerConnection?.iceConnectionState; + console.log('🧊 ICE connection state changed:', iceState); + + if (iceState === 'failed') { + console.warn('❌ ICE connection failed, attempting restart...'); + this.restartIce(); + } + }; + this.peerConnection.onicecandidate = (event) => { if (event.candidate && this.ws) { + console.log('📡 Sending ICE candidate:', event.candidate.type); this.ws.send(JSON.stringify({ type: 'ice_candidate', candidate: event.candidate })); } - }; - - this.peerConnection.onconnectionstatechange = () => { + }; this.peerConnection.onconnectionstatechange = () => { const state = this.peerConnection?.connectionState; console.log('🔗 Peer connection state changed:', state); + // 检测移动设备 + const isMobile = typeof window !== 'undefined' && + (/Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)); + if (state === 'connected') { console.log('🎉 Peer connected!'); clipboardState.update(state => ({ ...state, peerConnected: true })); - } else if (state === 'failed' || state === 'disconnected') { - console.warn('❌ Peer connection failed/disconnected'); + + // 移动端额外确认连接状态 + if (isMobile) { + setTimeout(() => { + console.log('📱 Mobile peer connection confirmation'); + clipboardState.update(state => ({ ...state, peerConnected: true })); + }, 150); + } + } else if (state === 'failed') { + console.warn('❌ Peer connection failed, retrying...'); clipboardState.update(state => ({ ...state, peerConnected: false })); + + // 移动端使用更短的重试间隔 + const retryDelay = isMobile ? 1000 : 2000; + setTimeout(() => { + if (this.peerConnection?.connectionState === 'failed') { + console.log(`🔄 Attempting to restart peer connection (${isMobile ? 'mobile' : 'desktop'})...`); + this.restartWebRTC(); + } + }, retryDelay); + } else if (state === 'disconnected') { + console.warn('⚠️ Peer connection disconnected'); + clipboardState.update(state => ({ ...state, peerConnected: false })); + + // 移动端快速恢复尝试 + if (isMobile) { + setTimeout(() => { + if (this.peerConnection?.connectionState === 'disconnected') { + console.log('📱 Mobile reconnection attempt...'); + this.restartWebRTC(); + } + }, 800); + } } - }; - - if (isInitiator) { + };if (isInitiator) { this.dataChannel = this.peerConnection.createDataChannel('files', { - ordered: true + ordered: true, + maxRetransmits: 3 // 增加重传次数 }); this.setupDataChannel(); + // 为移动端增加延迟,确保ICE candidates收集完成 + await new Promise(resolve => setTimeout(resolve, 500)); + const offer = await this.peerConnection.createOffer(); await this.peerConnection.setLocalDescription(offer); + console.log('📤 Sending offer to peer...'); if (this.ws) { this.ws.send(JSON.stringify({ type: 'offer', @@ -501,16 +638,59 @@ export class ClipboardManager { } } else { this.peerConnection.ondatachannel = (event) => { + console.log('📥 Data channel received'); this.dataChannel = event.channel; this.setupDataChannel(); }; } } catch (error) { console.error('Error setting up WebRTC:', error); + // 添加错误恢复 + setTimeout(() => { + console.log('🔄 Retrying WebRTC setup...'); + this.setupWebRTC(isInitiator); + }, 3000); } } - private setupDataChannel(): void { + // 添加ICE重启方法 + private async restartIce(): Promise { + try { + if (this.peerConnection && this.peerConnection.connectionState !== 'closed') { + console.log('🔄 Restarting ICE...'); + await this.peerConnection.restartIce(); + } + } catch (error) { + console.error('Error restarting ICE:', error); + } + } + + // 添加WebRTC重启方法 + private async restartWebRTC(): Promise { + try { + console.log('🔄 Restarting WebRTC connection...'); + + // 清理现有连接 + if (this.dataChannel) { + this.dataChannel.close(); + this.dataChannel = null; + } + if (this.peerConnection) { + this.peerConnection.close(); + this.peerConnection = null; + } + + // 获取当前状态以确定是否为发起者 + let currentState: any = {}; + const unsubscribe = clipboardState.subscribe(s => currentState = s); + unsubscribe(); + + // 重新建立连接 + await this.setupWebRTC(currentState.isCreator); + } catch (error) { + console.error('Error restarting WebRTC:', error); + } + } private setupDataChannel(): void { if (!this.dataChannel) return; // Update state with data channel @@ -521,7 +701,18 @@ export class ClipboardManager { this.dataChannel.onopen = () => { console.log('🎉 Data channel opened!'); + // 立即更新状态,不等待状态检查间隔 clipboardState.update(state => ({ ...state, peerConnected: true })); + + // 移动端额外的状态确认延迟,确保UI有足够时间响应 + const isMobile = typeof window !== 'undefined' && + (/Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)); + if (isMobile) { + setTimeout(() => { + console.log('📱 Mobile connection confirmation'); + clipboardState.update(state => ({ ...state, peerConnected: true })); + }, 200); + } }; this.dataChannel.onclose = () => { @@ -531,6 +722,17 @@ export class ClipboardManager { this.dataChannel.onerror = (error) => { console.error('Data channel error:', error); + // 移动端错误恢复机制 + const isMobile = typeof window !== 'undefined' && + (/Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)); + if (isMobile) { + console.log('📱 Mobile data channel error, attempting recovery...'); + setTimeout(() => { + if (this.dataChannel?.readyState === 'open') { + clipboardState.update(state => ({ ...state, peerConnected: true })); + } + }, 500); + } }; this.dataChannel.onmessage = async (event) => { console.log('📨 Data channel message received:', event.data); try { diff --git a/web/src/routes/clipboard/+page.svelte b/web/src/routes/clipboard/+page.svelte index 679a93c0..42e26dd1 100644 --- a/web/src/routes/clipboard/+page.svelte +++ b/web/src/routes/clipboard/+page.svelte @@ -48,6 +48,11 @@ let dataChannel: RTCDataChannel | null = null; let peerConnection: RTCPeerConnection | null = null; + // Error handling state + let errorMessage = ''; + let showError = false; + let waitingForCreator = false; + // Clipboard manager instance let clipboardManager: ClipboardManager; // Subscribe to clipboard state $: if (clipboardManager) { @@ -71,6 +76,9 @@ transferProgress = state.transferProgress; dataChannel = state.dataChannel; peerConnection = state.peerConnection; + errorMessage = state.errorMessage; + showError = state.showError; + waitingForCreator = state.waitingForCreator; }); } @@ -143,10 +151,15 @@ clipboardManager?.sendText(text); } else { console.log('No text to send'); - } - } function handleClearText() { + } } function handleClearText() { clipboardState.update(state => ({ ...state, receivedText: '' })); - }// Lifecycle functions + } + + function handleClearError() { + clipboardManager?.clearError(); + } + +// Lifecycle functions onMount(async () => { clipboardManager = new ClipboardManager(); @@ -171,27 +184,37 @@ -
+
+

{$t("clipboard.title")}

{$t("clipboard.description")}

{$t("clipboard.description_subtitle")}

+
+ + + {#if showError && errorMessage} +
+
+
+ {#if waitingForCreator} +
+ {:else} + ⚠️ + {/if} +
+ {waitingForCreator ? '等待中' : '提示'} +

{errorMessage}

+
+ +
-
- {#if isConnected && peerConnected} + {/if} + + {#if isConnected && peerConnected} +
@@ -199,13 +222,13 @@
- + - +
{#if activeTab === 'files'} @@ -233,8 +256,41 @@ on:sendText={handleSendText} on:clearText={handleClearText} bind:textContent - /> {/if} + /> + {/if}
+ + +
+
+

会话管理

+

会话ID: {sessionId}

+
+ + +
+
+
+ {:else} + + {/if}
@@ -302,9 +358,106 @@ font-weight: 400 !important; opacity: 0.7 !important; margin: 0; - max-width: 600px; - line-height: 1.4; - } /* Enhanced session status */ + max-width: 600px; line-height: 1.4; + } + + /* Error notification styles */ + .error-notification { + margin: 1rem 0; + padding: 1rem; + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: 12px; + backdrop-filter: blur(8px); + animation: slideIn 0.3s ease-out; + } + + .error-notification.waiting { + background: rgba(34, 197, 94, 0.1); + border-color: rgba(34, 197, 94, 0.3); + } + + .error-content { + display: flex; + align-items: flex-start; + gap: 0.75rem; + } + + .error-icon { + flex-shrink: 0; + font-size: 1.2rem; + margin-top: 0.1rem; + } + + .spinner { + width: 20px; + height: 20px; + border: 2px solid rgba(34, 197, 94, 0.3); + border-top: 2px solid #22c55e; + border-radius: 50%; + animation: spin 1s linear infinite; + } + + .error-text { + flex: 1; + } + + .error-text strong { + color: #ef4444; + font-weight: 600; + display: block; + margin-bottom: 0.25rem; + } + + .error-notification.waiting .error-text strong { + color: #22c55e; + } + + .error-text p { + color: var(--text); + margin: 0; + font-size: 0.9rem; + opacity: 0.9; + } + + .error-close { + background: none; + border: none; + color: var(--text); + font-size: 1.2rem; + cursor: pointer; + opacity: 0.7; + transition: opacity 0.2s ease; + padding: 0; + line-height: 1; + flex-shrink: 0; + } + + .error-close:hover { + opacity: 1; + } + + @keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } + + /* Enhanced session status */ .session-status { display: flex; align-items: center; @@ -357,11 +510,86 @@ backdrop-filter: blur(8px); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05); transition: all 0.3s ease; + position: relative; + overflow: visible; /* 允许通知显示在容器外 */ } .tab-content:hover { box-shadow: 0 6px 30px rgba(0, 0, 0, 0.08); - border-color: rgba(255, 255, 255, 0.12); } + border-color: rgba(255, 255, 255, 0.12); + } + + /* Session management section styling */ + .session-management-section { + margin-top: 1.5rem; + padding: 1rem; + background: rgba(255, 255, 255, 0.03); + border-radius: 15px; + border: 1px solid rgba(255, 255, 255, 0.08); + backdrop-filter: blur(8px); + } + + .session-info { + text-align: center; + } + + .session-info h3 { + color: var(--text); + font-size: 1.2rem; + font-weight: 600; + margin-bottom: 0.5rem; + } + + .session-info p { + color: var(--subtext); + font-size: 0.9rem; + margin-bottom: 1rem; + } + + .session-info code { + background: rgba(255, 255, 255, 0.1); + padding: 0.2rem 0.5rem; + border-radius: 6px; + font-family: 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', monospace; + color: var(--accent); + font-weight: 500; + } + + .session-actions { + display: flex; + gap: 0.75rem; + justify-content: center; + flex-wrap: wrap; + } + + .btn-secondary { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 10px; + padding: 0.6rem 1.2rem; + color: var(--text); + font-weight: 500; + transition: all 0.3s ease; + cursor: pointer; + font-size: 0.9rem; + } + + .btn-secondary:hover { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.25); + transform: translateY(-1px); + } + + .btn-secondary.danger { + background: rgba(239, 68, 68, 0.1); + border-color: rgba(239, 68, 68, 0.3); + color: #ef4444; + } + + .btn-secondary.danger:hover { + background: rgba(239, 68, 68, 0.15); + border-color: rgba(239, 68, 68, 0.4); + } /* Enhanced card-like sections */ :global(.card) {