From 97381db69b88a6e355d1a0c9bfc1522f4100d846 Mon Sep 17 00:00:00 2001 From: celebrateyang Date: Wed, 4 Jun 2025 22:54:49 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=87=E4=BB=B6=E4=BC=A0=E8=BE=935?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/clipboard/DebugPanel.svelte | 160 ++ .../components/clipboard/FileTransfer.svelte | 334 +++ .../clipboard/SessionManager.svelte | 265 +++ .../components/clipboard/TabNavigation.svelte | 84 + .../components/clipboard/TextSharing.svelte | 189 ++ .../lib/clipboard/clipboard-manager-new.ts | 776 +++++++ web/src/lib/clipboard/clipboard-manager.ts | 776 +++++++ .../lib/clipboard/clipboard-manager.ts.broken | 793 +++++++ web/src/routes/clipboard/+page.svelte | 1884 ++--------------- web/src/routes/clipboard/+page.svelte.backup | 1112 ---------- 10 files changed, 3547 insertions(+), 2826 deletions(-) create mode 100644 web/src/components/clipboard/DebugPanel.svelte create mode 100644 web/src/components/clipboard/FileTransfer.svelte create mode 100644 web/src/components/clipboard/SessionManager.svelte create mode 100644 web/src/components/clipboard/TabNavigation.svelte create mode 100644 web/src/components/clipboard/TextSharing.svelte create mode 100644 web/src/lib/clipboard/clipboard-manager-new.ts create mode 100644 web/src/lib/clipboard/clipboard-manager.ts create mode 100644 web/src/lib/clipboard/clipboard-manager.ts.broken delete mode 100644 web/src/routes/clipboard/+page.svelte.backup diff --git a/web/src/components/clipboard/DebugPanel.svelte b/web/src/components/clipboard/DebugPanel.svelte new file mode 100644 index 00000000..a1ee5c61 --- /dev/null +++ b/web/src/components/clipboard/DebugPanel.svelte @@ -0,0 +1,160 @@ + + + +
+
+ ๐Ÿ”ง Connection Debug +
+

WebSocket: {isConnected ? 'โœ… Connected' : 'โŒ Disconnected'}

+

Session ID: {sessionId || 'Not set'}

+

Is Creator: {isCreator ? 'Yes' : 'No'}

+

Peer Connected: {peerConnected ? 'โœ… Yes' : 'โŒ No'}

+

Data Channel: {dataChannel ? (dataChannel.readyState || 'Unknown') : 'Not created'}

+

Peer Connection: {peerConnection ? (peerConnection.connectionState || 'Unknown') : 'Not created'}

+

Signaling State: {peerConnection ? (peerConnection.signalingState || 'Unknown') : 'Not created'}

+

ICE Connection: {peerConnection ? (peerConnection.iceConnectionState || 'Unknown') : 'Not created'}

+

ICE Gathering: {peerConnection ? (peerConnection.iceGatheringState || 'Unknown') : 'Not created'}

+ +
+ + + {#if dataChannel && dataChannel.readyState === 'open' && !peerConnected} + + {/if} +
+ + {#if dataChannel && dataChannel.readyState === 'connecting'} +
+ โš ๏ธ Data channel stuck in 'connecting' state +
    +
  • This usually indicates network connectivity issues
  • +
  • Check firewall settings and network restrictions
  • +
  • Try restarting the WebRTC connection
  • +
+
+ {/if} +
+
+
+
+ + diff --git a/web/src/components/clipboard/FileTransfer.svelte b/web/src/components/clipboard/FileTransfer.svelte new file mode 100644 index 00000000..e8014c9f --- /dev/null +++ b/web/src/components/clipboard/FileTransfer.svelte @@ -0,0 +1,334 @@ + + + +
+
+

ๅ‘้€ๆ–‡ไปถ

+ +
fileInput?.click()} + on:keydown={(e) => e.key === 'Enter' && fileInput?.click()} + > +

๐Ÿ“ ๆ‹–ๆ‹ฝๆ–‡ไปถๅˆฐ่ฟ™้‡Œๆˆ–็‚นๅ‡ป้€‰ๆ‹ฉ

+ +
+ + {#if files.length > 0} +
+
ๅพ…ๅ‘้€ๆ–‡ไปถ:
+ {#each files as file, index (file.name + index)} +
+ {file.name} + ({formatFileSize(file.size)}) + +
+ {/each} + + {sendingFiles ? 'ๅ‘้€ไธญ...' : 'ๅ‘้€ๆ–‡ไปถ'} + +
+ {/if} + + {#if sendingFiles} +
+

ๅ‘้€่ฟ›ๅบฆ: {Math.round(transferProgress)}%

+
+
+
+
+ {/if} +
+ +
+

ๅทฒๆŽฅๆ”ถๆ–‡ไปถ

+ + {#if receivingFiles} +
+

ๆŽฅๆ”ถ่ฟ›ๅบฆ: {Math.round(transferProgress)}%

+
+
+
+
+ {/if} + + {#if receivedFiles.length > 0} +
+ {#each receivedFiles as file, index (file.name + index)} +
+ {file.name} + ({formatFileSize(file.size)}) +
+ + +
+
+ {/each} +
+ {:else if !receivingFiles} +
+ ๆš‚ๆ— ๆŽฅๆ”ถๅˆฐ็š„ๆ–‡ไปถ +
+ {/if} +
+
+
+ + diff --git a/web/src/components/clipboard/SessionManager.svelte b/web/src/components/clipboard/SessionManager.svelte new file mode 100644 index 00000000..6c2dc0bb --- /dev/null +++ b/web/src/components/clipboard/SessionManager.svelte @@ -0,0 +1,265 @@ + + +{#if !isConnected} + +
+
+

{$t("clipboard.create_session")}

+

{$t("clipboard.create_description")}

+ {isCreating ? 'Creating...' : $t("clipboard.create_session")} + +
+ +
+ {$t("general.or")} +
+ +
+

{$t("clipboard.join_session")}

+

{$t("clipboard.join_description")}

+
+ + {isJoining ? 'Joining...' : $t("clipboard.join_session")} + +
+
+
+
+{:else} + + +
+
+
+ Session ID: + {sessionId} + +
+ + {#if isCreator && sessionId && !peerConnected && qrCodeUrl} +
+

Scan to join:

+ QR Code +
+ {/if} + +
+
+ {peerConnected ? 'Connected' : 'Waiting for peer...'} +
+
+
+
+ + +
+ + {$t("clipboard.disconnect")} + +
+{/if} + + diff --git a/web/src/components/clipboard/TabNavigation.svelte b/web/src/components/clipboard/TabNavigation.svelte new file mode 100644 index 00000000..73adb867 --- /dev/null +++ b/web/src/components/clipboard/TabNavigation.svelte @@ -0,0 +1,84 @@ + + +
+ + +
+ + diff --git a/web/src/components/clipboard/TextSharing.svelte b/web/src/components/clipboard/TextSharing.svelte new file mode 100644 index 00000000..6e18c087 --- /dev/null +++ b/web/src/components/clipboard/TextSharing.svelte @@ -0,0 +1,189 @@ + + + +
+
+

ๅ‘้€ๆ–‡ๆœฌ

+ + ๅ‘้€ๆ–‡ๆœฌ + +
+ +
+

ๅทฒๆŽฅๆ”ถๆ–‡ๆœฌ

+ {#if receivedText} +
+
{receivedText}
+
+ + +
+
+ {:else} +
+ ๆš‚ๆ— ๆŽฅๆ”ถๅˆฐ็š„ๆ–‡ๆœฌ +
+ {/if} +
+
+
+ + diff --git a/web/src/lib/clipboard/clipboard-manager-new.ts b/web/src/lib/clipboard/clipboard-manager-new.ts new file mode 100644 index 00000000..5abc2bcd --- /dev/null +++ b/web/src/lib/clipboard/clipboard-manager-new.ts @@ -0,0 +1,776 @@ +// WebRTC and WebSocket management composable +import { writable } from 'svelte/store'; +import { currentApiURL } from '$lib/api/api-url'; +import QRCode from 'qrcode'; + +// Types +export interface FileItem { + name: string; + size: number; + type: string; + blob: Blob; +} + +interface ReceivingFile { + name: string; + size: number; + type: string; + chunks: Uint8Array[]; + receivedSize: number; +} + +// Constants +const CHUNK_SIZE = 64 * 1024; // 64KB chunks for file transfer + +// Store for reactive state +export const clipboardState = writable({ + sessionId: '', + isConnected: false, + isCreating: false, + isJoining: false, + isCreator: false, + peerConnected: false, + qrCodeUrl: '', + activeTab: 'files' as 'files' | 'text', + files: [] as File[], + receivedFiles: [] as FileItem[], + textContent: '', + receivedText: '', + dragover: false, + sendingFiles: false, + receivingFiles: false, + transferProgress: 0, + dataChannel: null as RTCDataChannel | null, + peerConnection: null as RTCPeerConnection | null +}); + +export class ClipboardManager { + private ws: WebSocket | null = null; + private peerConnection: RTCPeerConnection | null = null; + private dataChannel: RTCDataChannel | null = null; + private keyPair: CryptoKeyPair | null = null; + private remotePublicKey: CryptoKey | null = null; + private sharedKey: CryptoKey | null = null; + private currentReceivingFile: ReceivingFile | null = null; + private statusInterval: ReturnType | null = null; + + constructor() { + this.loadStoredSession(); + this.startStatusCheck(); + } + + // Session persistence + private loadStoredSession(): void { + if (typeof window !== 'undefined') { + const stored = localStorage.getItem('clipboard_session'); + if (stored) { + try { + const session = JSON.parse(stored); + clipboardState.update(state => ({ + ...state, + sessionId: session.sessionId, + isCreator: session.isCreator + })); + } catch (e) { + this.clearStoredSession(); + } + } + } + } + + private saveSession(sessionId: string, isCreator: boolean): void { + if (typeof window !== 'undefined' && sessionId) { + localStorage.setItem('clipboard_session', JSON.stringify({ + sessionId, + isCreator, + timestamp: Date.now() + })); + } + } + + private clearStoredSession(): void { + if (typeof window !== 'undefined') { + localStorage.removeItem('clipboard_session'); + } + } + + private startStatusCheck(): void { + 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); + } + + // WebSocket management + private getWebSocketURL(): string { + if (typeof window === 'undefined') return 'ws://localhost:9000/ws'; + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + let host = window.location.host; + + // For mobile access, use the actual IP instead of localhost + if (host.includes('localhost') || host.includes('127.0.0.1')) { + host = '192.168.1.12:5173'; + } + + const wsUrl = `${protocol}//${host}/ws`; + console.log('Constructed WebSocket URL:', wsUrl); + return wsUrl; + } + + private async connectWebSocket(): Promise { + return new Promise((resolve, reject) => { + try { + const wsUrl = this.getWebSocketURL(); + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + console.log('๐Ÿ”— WebSocket connected'); + clipboardState.update(state => ({ ...state, isConnected: true })); + resolve(); + }; + + this.ws.onmessage = async (event) => { + try { + const message = JSON.parse(event.data); + await this.handleWebSocketMessage(message); + } catch (error) { + console.error('Error parsing WebSocket message:', error); + } + }; + + this.ws.onclose = () => { + console.log('๐Ÿ”Œ WebSocket disconnected'); + clipboardState.update(state => ({ ...state, isConnected: false })); + }; + + this.ws.onerror = (error) => { + console.error('โŒ WebSocket error:', error); + reject(error); + }; + } catch (error) { + console.error('Error creating WebSocket:', error); + reject(error); + } + }); + } + + // Encryption + private async generateKeyPair(): Promise { + this.keyPair = await window.crypto.subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, + false, + ['deriveKey'] + ); + } + + private async exportPublicKey(): Promise { + if (!this.keyPair) throw new Error('Key pair not generated'); + return await window.crypto.subtle.exportKey('raw', this.keyPair.publicKey); + } + + private async importRemotePublicKey(publicKeyArray: number[]): Promise { + const publicKeyBuffer = new Uint8Array(publicKeyArray).buffer; + this.remotePublicKey = await window.crypto.subtle.importKey( + 'raw', + publicKeyBuffer, + { name: 'ECDH', namedCurve: 'P-256' }, + false, + [] + ); + } + + private async deriveSharedKey(): Promise { + if (!this.keyPair || !this.remotePublicKey) throw new Error('Keys not available'); + + this.sharedKey = await window.crypto.subtle.deriveKey( + { name: 'ECDH', public: this.remotePublicKey }, + this.keyPair.privateKey, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); + } + + private async encryptData(data: string): Promise { + if (!this.sharedKey) throw new Error('Shared key not available'); + + const encoder = new TextEncoder(); + const dataBuffer = encoder.encode(data); + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + + const encrypted = await window.crypto.subtle.encrypt( + { name: 'AES-GCM', iv: iv }, + this.sharedKey, + dataBuffer + ); + + // Combine IV and encrypted data + const result = new Uint8Array(iv.length + encrypted.byteLength); + result.set(iv); + result.set(new Uint8Array(encrypted), iv.length); + + return result.buffer; + } + + private async encryptBinaryData(data: Uint8Array): Promise { + if (!this.sharedKey) throw new Error('Shared key not available'); + + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + + const encrypted = await window.crypto.subtle.encrypt( + { name: 'AES-GCM', iv: iv }, + this.sharedKey, + data + ); + + // Combine IV and encrypted data + const result = new Uint8Array(iv.length + encrypted.byteLength); + result.set(iv); + result.set(new Uint8Array(encrypted), iv.length); + + return result.buffer; + } + + private async decryptData(encryptedBuffer: ArrayBuffer): Promise { + if (!this.sharedKey) throw new Error('Shared key not available'); + + const encryptedArray = new Uint8Array(encryptedBuffer); + const iv = encryptedArray.slice(0, 12); + const encrypted = encryptedArray.slice(12); + + const decrypted = await window.crypto.subtle.decrypt( + { name: 'AES-GCM', iv: iv }, + this.sharedKey, + encrypted + ); + + const decoder = new TextDecoder(); + return decoder.decode(decrypted); + } + + private async decryptBinaryData(encryptedBuffer: ArrayBuffer): Promise { + if (!this.sharedKey) throw new Error('Shared key not available'); + + const encryptedArray = new Uint8Array(encryptedBuffer); + const iv = encryptedArray.slice(0, 12); + const encrypted = encryptedArray.slice(12); + + const decrypted = await window.crypto.subtle.decrypt( + { name: 'AES-GCM', iv: iv }, + this.sharedKey, + encrypted + ); + + return new Uint8Array(decrypted); + } + + // Public API methods + async createSession(): Promise { + try { + clipboardState.update(state => ({ ...state, isCreating: true })); + await this.generateKeyPair(); + await this.connectWebSocket(); + + const publicKeyBuffer = await this.exportPublicKey(); + const publicKeyArray = Array.from(new Uint8Array(publicKeyBuffer)); + + if (this.ws) { + this.ws.send(JSON.stringify({ + type: 'create_session', + publicKey: publicKeyArray + })); + } + } catch (error) { + console.error('Error creating session:', error); + clipboardState.update(state => ({ ...state, isCreating: false })); + } + } + + async joinSession(joinCode: string): Promise { + try { + console.log('Starting join session process...'); + clipboardState.update(state => ({ ...state, isJoining: true })); + + await this.generateKeyPair(); + await this.connectWebSocket(); + + const publicKeyBuffer = await this.exportPublicKey(); + const publicKeyArray = Array.from(new Uint8Array(publicKeyBuffer)); + + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + const message = { + type: 'join_session', + sessionId: joinCode, + publicKey: publicKeyArray + }; + console.log('Sending join message:', message); + this.ws.send(JSON.stringify(message)); + } else { + console.error('WebSocket not ready'); + clipboardState.update(state => ({ ...state, isJoining: false })); + } + } catch (error) { + console.error('Error joining session:', error); + clipboardState.update(state => ({ ...state, isJoining: false })); + } + } + + async generateQRCode(sessionId: string): Promise { + try { + if (typeof window !== 'undefined' && sessionId) { + let origin = window.location.origin; + if (origin.includes('localhost') || origin.includes('127.0.0.1')) { + origin = origin.replace(/localhost:\d+|127\.0\.0\.1:\d+/, '192.168.1.12:5173'); + } + + const url = `${origin}/clipboard?session=${sessionId}`; + const qrCodeUrl = await QRCode.toDataURL(url, { + color: { dark: '#000000', light: '#ffffff' } + }); + + clipboardState.update(state => ({ ...state, qrCodeUrl })); + console.log('QR Code generated'); + } + } catch (error) { + console.error('QR Code generation failed:', error); + } + } + + shareSession(sessionId: string): void { + if (typeof window !== 'undefined' && sessionId) { + let origin = window.location.origin; + if (origin.includes('localhost') || origin.includes('127.0.0.1')) { + origin = origin.replace(/localhost:\d+|127\.0\.0\.1:\d+/, '192.168.1.12:5173'); + } + + const url = `${origin}/clipboard?session=${sessionId}`; + navigator.clipboard.writeText(url); + } + } + + cleanup(): void { + if (this.dataChannel) { + this.dataChannel.close(); + this.dataChannel = null; + } + if (this.peerConnection) { + this.peerConnection.close(); + this.peerConnection = null; + } + if (this.ws) { + this.ws.close(); + this.ws = null; + } + if (this.statusInterval) { + clearInterval(this.statusInterval); + } + + clipboardState.update(state => ({ + ...state, + sessionId: '', + isConnected: false, + peerConnected: false, + qrCodeUrl: '' + })); + + this.sharedKey = null; + this.remotePublicKey = null; + this.clearStoredSession(); + } + + // WebSocket message handler + private async handleWebSocketMessage(message: any): Promise { + console.log('Handling WebSocket message:', message.type); + + switch (message.type) { + case 'session_created': + clipboardState.update(state => ({ + ...state, + sessionId: message.sessionId, + isCreating: false, + isCreator: true + })); + await this.generateQRCode(message.sessionId); + this.saveSession(message.sessionId, true); + break; + + case 'session_joined': + console.log('Session joined successfully, setting up WebRTC...'); + await this.importRemotePublicKey(message.publicKey); + await this.deriveSharedKey(); + clipboardState.update(state => ({ ...state, isJoining: false })); + await this.setupWebRTC(false); + break; + + case 'peer_joined': + console.log('Peer joined, setting up WebRTC...'); + await this.importRemotePublicKey(message.publicKey); + await this.deriveSharedKey(); + + // Get current state to check if this is the creator + let currentState: any = {}; + const unsubscribe = clipboardState.subscribe(s => currentState = s); + unsubscribe(); + + if (currentState.isCreator) { + await this.setupWebRTC(true); + } else { + await this.setupWebRTC(false); + } + break; + + case 'offer': + await this.handleOffer(message.offer); + break; + + case 'answer': + await this.handleAnswer(message.answer); + break; + + case 'ice_candidate': + await this.handleIceCandidate(message.candidate); + break; + + case 'error': + console.error('Server error:', message); + clipboardState.update(state => ({ + ...state, + isCreating: false, + isJoining: false + })); + break; + } + } + + // WebRTC setup and handlers + private async setupWebRTC(isInitiator: boolean): Promise { + try { + this.peerConnection = new RTCPeerConnection({ + iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] + }); + + // Update state with peer connection + clipboardState.update(state => ({ + ...state, + peerConnection: this.peerConnection + })); + + this.peerConnection.onicecandidate = (event) => { + if (event.candidate && this.ws) { + this.ws.send(JSON.stringify({ + type: 'ice_candidate', + candidate: event.candidate + })); + } + }; + + this.peerConnection.onconnectionstatechange = () => { + const state = this.peerConnection?.connectionState; + console.log('๐Ÿ”— Peer connection state changed:', state); + + 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'); + clipboardState.update(state => ({ ...state, peerConnected: false })); + } + }; + + if (isInitiator) { + this.dataChannel = this.peerConnection.createDataChannel('files', { + ordered: true + }); + this.setupDataChannel(); + + const offer = await this.peerConnection.createOffer(); + await this.peerConnection.setLocalDescription(offer); + + if (this.ws) { + this.ws.send(JSON.stringify({ + type: 'offer', + offer + })); + } + } else { + this.peerConnection.ondatachannel = (event) => { + this.dataChannel = event.channel; + this.setupDataChannel(); + }; + } + } catch (error) { + console.error('Error setting up WebRTC:', error); + } + } + + private setupDataChannel(): void { + if (!this.dataChannel) return; + + // Update state with data channel + clipboardState.update(state => ({ + ...state, + dataChannel: this.dataChannel + })); + + this.dataChannel.onopen = () => { + console.log('๐ŸŽ‰ Data channel opened!'); + clipboardState.update(state => ({ ...state, peerConnected: true })); + }; + + this.dataChannel.onclose = () => { + console.log('โŒ Data channel closed'); + clipboardState.update(state => ({ ...state, peerConnected: false })); + }; + + this.dataChannel.onerror = (error) => { + console.error('Data channel error:', error); + }; + + this.dataChannel.onmessage = async (event) => { + try { + const data = JSON.parse(event.data); + await this.handleDataChannelMessage(data); + } catch (error) { + console.error('Error handling data channel message:', error); + } + }; + } + + private async handleOffer(offer: RTCSessionDescriptionInit): Promise { + try { + if (this.peerConnection) { + await this.peerConnection.setRemoteDescription(offer); + const answer = await this.peerConnection.createAnswer(); + await this.peerConnection.setLocalDescription(answer); + + if (this.ws) { + this.ws.send(JSON.stringify({ + type: 'answer', + answer + })); + } + } + } catch (error) { + console.error('Error handling offer:', error); + } + } + + private async handleAnswer(answer: RTCSessionDescriptionInit): Promise { + try { + if (this.peerConnection) { + await this.peerConnection.setRemoteDescription(answer); + } + } catch (error) { + console.error('Error handling answer:', error); + } + } + + private async handleIceCandidate(candidate: RTCIceCandidateInit): Promise { + try { + if (this.peerConnection) { + await this.peerConnection.addIceCandidate(candidate); + } + } catch (error) { + console.error('Error handling ICE candidate:', error); + } + } + + private async handleDataChannelMessage(data: any): Promise { + // Handle different types of data channel messages + switch (data.type) { + case 'text': + // Convert array back to ArrayBuffer for decryption + const encryptedBuffer = new Uint8Array(data.content).buffer; + const decryptedText = await this.decryptData(encryptedBuffer); + clipboardState.update(state => ({ + ...state, + receivedText: decryptedText + })); + console.log('Text received successfully'); + break; + + case 'file_start': + // Start receiving a new file + this.currentReceivingFile = { + name: data.name, + size: data.size, + type: data.mimeType || data.type, + chunks: [], + receivedSize: 0 + }; + clipboardState.update(state => ({ + ...state, + receivingFiles: true, + transferProgress: 0 + })); + console.log('Started receiving file:', data.name); + break; + + case 'file_chunk': + if (this.currentReceivingFile) { + // Decrypt and store chunk + const encryptedData = new Uint8Array(data.data).buffer; + const decryptedChunk = await this.decryptBinaryData(encryptedData); + + this.currentReceivingFile.chunks.push(decryptedChunk); + this.currentReceivingFile.receivedSize += decryptedChunk.length; + + // Update progress + const progress = (this.currentReceivingFile.receivedSize / this.currentReceivingFile.size) * 100; + clipboardState.update(state => ({ + ...state, + transferProgress: Math.min(progress, 100) + })); + } + break; + + case 'file_end': + if (this.currentReceivingFile && this.currentReceivingFile.name === data.name) { + // Combine all chunks into a single file + const totalSize = this.currentReceivingFile.chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const combinedArray = new Uint8Array(totalSize); + let offset = 0; + + for (const chunk of this.currentReceivingFile.chunks) { + combinedArray.set(chunk, offset); + offset += chunk.length; + } + + const blob = new Blob([combinedArray], { type: this.currentReceivingFile.type }); + const fileItem: FileItem = { + name: this.currentReceivingFile.name, + size: this.currentReceivingFile.size, + type: this.currentReceivingFile.type, + blob: blob + }; + + // Add to received files + clipboardState.update(state => ({ + ...state, + receivedFiles: [...state.receivedFiles, fileItem], + receivingFiles: false, + transferProgress: 0 + })); + + console.log('File received successfully:', data.name); + this.currentReceivingFile = null; + } + break; + } + } + + // Public methods for sending data + async sendText(text: string): Promise { + if (!this.dataChannel || this.dataChannel.readyState !== 'open') { + console.error('Data channel not ready'); + return; + } + + try { + const encryptedText = await this.encryptData(text); + // Convert ArrayBuffer to Array for JSON serialization + const encryptedArray = Array.from(new Uint8Array(encryptedText)); + const message = { + type: 'text', + content: encryptedArray + }; + + this.dataChannel.send(JSON.stringify(message)); + console.log('Text sent successfully'); + } catch (error) { + console.error('Error sending text:', error); + } + } + + async sendFiles(): Promise { + if (!this.dataChannel || this.dataChannel.readyState !== 'open') { + console.error('Data channel not ready'); + return; + } + + let currentFiles: File[] = []; + const unsubscribe = clipboardState.subscribe(state => { + currentFiles = state.files; + }); + unsubscribe(); + + if (currentFiles.length === 0) { + console.log('No files to send'); + return; + } + + try { + clipboardState.update(state => ({ ...state, sendingFiles: true, transferProgress: 0 })); + + for (let i = 0; i < currentFiles.length; i++) { + const file = currentFiles[i]; + await this.sendSingleFile(file); + + // Update progress + const progress = ((i + 1) / currentFiles.length) * 100; + clipboardState.update(state => ({ ...state, transferProgress: progress })); + } + + clipboardState.update(state => ({ ...state, sendingFiles: false, transferProgress: 0 })); + console.log('All files sent successfully'); + } catch (error) { + console.error('Error sending files:', error); + clipboardState.update(state => ({ ...state, sendingFiles: false, transferProgress: 0 })); + } + } + + private async sendSingleFile(file: File): Promise { + // Send file start message + const fileStartMessage = { + type: 'file_start', + name: file.name, + size: file.size, + mimeType: file.type + }; + + this.dataChannel!.send(JSON.stringify(fileStartMessage)); + + // Send file in chunks + const arrayBuffer = await file.arrayBuffer(); + const totalChunks = Math.ceil(arrayBuffer.byteLength / CHUNK_SIZE); + + for (let i = 0; i < totalChunks; i++) { + const start = i * CHUNK_SIZE; + const end = Math.min(start + CHUNK_SIZE, arrayBuffer.byteLength); + const chunk = arrayBuffer.slice(start, end); + const encryptedChunk = await this.encryptBinaryData(new Uint8Array(chunk)); + + const chunkMessage = { + type: 'file_chunk', + index: i, + data: Array.from(new Uint8Array(encryptedChunk)) + }; + + this.dataChannel!.send(JSON.stringify(chunkMessage)); + } + + // Send file end message + const fileEndMessage = { + type: 'file_end', + name: file.name + }; + + this.dataChannel!.send(JSON.stringify(fileEndMessage)); + } + + // Expose peer connection and data channel for debug panel + get debugInfo() { + return { + peerConnection: this.peerConnection, + dataChannel: this.dataChannel, + sharedKey: this.sharedKey ? 'Present' : 'Not available' + }; + } +} diff --git a/web/src/lib/clipboard/clipboard-manager.ts b/web/src/lib/clipboard/clipboard-manager.ts new file mode 100644 index 00000000..9444065b --- /dev/null +++ b/web/src/lib/clipboard/clipboard-manager.ts @@ -0,0 +1,776 @@ +// WebRTC and WebSocket management composable +import { writable } from 'svelte/store'; +import { currentApiURL } from '$lib/api/api-url'; +import QRCode from 'qrcode'; + +// Types +export interface FileItem { + name: string; + size: number; + type: string; + blob: Blob; +} + +interface ReceivingFile { + name: string; + size: number; + type: string; + chunks: Uint8Array[]; + receivedSize: number; +} + +// Constants +const CHUNK_SIZE = 64 * 1024; // 64KB chunks for file transfer + +// Store for reactive state +export const clipboardState = writable({ + sessionId: '', + isConnected: false, + isCreating: false, + isJoining: false, + isCreator: false, + peerConnected: false, + qrCodeUrl: '', + activeTab: 'files' as 'files' | 'text', + files: [] as File[], + receivedFiles: [] as FileItem[], + textContent: '', + receivedText: '', + dragover: false, + sendingFiles: false, + receivingFiles: false, + transferProgress: 0, + dataChannel: null as RTCDataChannel | null, + peerConnection: null as RTCPeerConnection | null +}); + +export class ClipboardManager { + private ws: WebSocket | null = null; + private peerConnection: RTCPeerConnection | null = null; + private dataChannel: RTCDataChannel | null = null; + private keyPair: CryptoKeyPair | null = null; + private remotePublicKey: CryptoKey | null = null; + private sharedKey: CryptoKey | null = null; + private currentReceivingFile: ReceivingFile | null = null; + private statusInterval: ReturnType | null = null; + + constructor() { + this.loadStoredSession(); + this.startStatusCheck(); + } + + // Session persistence + private loadStoredSession(): void { + if (typeof window !== 'undefined') { + const stored = localStorage.getItem('clipboard_session'); + if (stored) { + try { + const session = JSON.parse(stored); + clipboardState.update(state => ({ + ...state, + sessionId: session.sessionId, + isCreator: session.isCreator + })); + } catch (e) { + this.clearStoredSession(); + } + } + } + } + + private saveSession(sessionId: string, isCreator: boolean): void { + if (typeof window !== 'undefined' && sessionId) { + localStorage.setItem('clipboard_session', JSON.stringify({ + sessionId, + isCreator, + timestamp: Date.now() + })); + } + } + + private clearStoredSession(): void { + if (typeof window !== 'undefined') { + localStorage.removeItem('clipboard_session'); + } + } + + private startStatusCheck(): void { + 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); + } + + // WebSocket management + private getWebSocketURL(): string { + if (typeof window === 'undefined') return 'ws://localhost:9000/ws'; + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + let host = window.location.host; + + // For mobile access, use the actual IP instead of localhost + if (host.includes('localhost') || host.includes('127.0.0.1')) { + host = '192.168.1.12:5173'; + } + + const wsUrl = `${protocol}//${host}/ws`; + console.log('Constructed WebSocket URL:', wsUrl); + return wsUrl; + } + + private async connectWebSocket(): Promise { + return new Promise((resolve, reject) => { + try { + const wsUrl = this.getWebSocketURL(); + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + console.log('๐Ÿ”— WebSocket connected'); + clipboardState.update(state => ({ ...state, isConnected: true })); + resolve(); + }; + + this.ws.onmessage = async (event) => { + try { + const message = JSON.parse(event.data); + await this.handleWebSocketMessage(message); + } catch (error) { + console.error('Error parsing WebSocket message:', error); + } + }; + + this.ws.onclose = () => { + console.log('๐Ÿ”Œ WebSocket disconnected'); + clipboardState.update(state => ({ ...state, isConnected: false })); + }; + + this.ws.onerror = (error) => { + console.error('โŒ WebSocket error:', error); + reject(error); + }; + } catch (error) { + console.error('Error creating WebSocket:', error); + reject(error); + } + }); + } + + // Encryption + private async generateKeyPair(): Promise { + this.keyPair = await window.crypto.subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, + false, + ['deriveKey'] + ); + } + + private async exportPublicKey(): Promise { + if (!this.keyPair) throw new Error('Key pair not generated'); + return await window.crypto.subtle.exportKey('raw', this.keyPair.publicKey); + } + + private async importRemotePublicKey(publicKeyArray: number[]): Promise { + const publicKeyBuffer = new Uint8Array(publicKeyArray).buffer; + this.remotePublicKey = await window.crypto.subtle.importKey( + 'raw', + publicKeyBuffer, + { name: 'ECDH', namedCurve: 'P-256' }, + false, + [] + ); + } + + private async deriveSharedKey(): Promise { + if (!this.keyPair || !this.remotePublicKey) throw new Error('Keys not available'); + + this.sharedKey = await window.crypto.subtle.deriveKey( + { name: 'ECDH', public: this.remotePublicKey }, + this.keyPair.privateKey, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); + } + + private async encryptData(data: string): Promise { + if (!this.sharedKey) throw new Error('Shared key not available'); + + const encoder = new TextEncoder(); + const dataBuffer = encoder.encode(data); + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + + const encrypted = await window.crypto.subtle.encrypt( + { name: 'AES-GCM', iv: iv }, + this.sharedKey, + dataBuffer + ); + + // Combine IV and encrypted data + const result = new Uint8Array(iv.length + encrypted.byteLength); + result.set(iv); + result.set(new Uint8Array(encrypted), iv.length); + + return result.buffer; + } + + private async encryptBinaryData(data: Uint8Array): Promise { + if (!this.sharedKey) throw new Error('Shared key not available'); + + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + + const encrypted = await window.crypto.subtle.encrypt( + { name: 'AES-GCM', iv: iv }, + this.sharedKey, + data + ); + + // Combine IV and encrypted data + const result = new Uint8Array(iv.length + encrypted.byteLength); + result.set(iv); + result.set(new Uint8Array(encrypted), iv.length); + + return result.buffer; + } + + private async decryptData(encryptedBuffer: ArrayBuffer): Promise { + if (!this.sharedKey) throw new Error('Shared key not available'); + + const encryptedArray = new Uint8Array(encryptedBuffer); + const iv = encryptedArray.slice(0, 12); + const encrypted = encryptedArray.slice(12); + + const decrypted = await window.crypto.subtle.decrypt( + { name: 'AES-GCM', iv: iv }, + this.sharedKey, + encrypted + ); + + const decoder = new TextDecoder(); + return decoder.decode(decrypted); + } + + private async decryptBinaryData(encryptedBuffer: ArrayBuffer): Promise { + if (!this.sharedKey) throw new Error('Shared key not available'); + + const encryptedArray = new Uint8Array(encryptedBuffer); + const iv = encryptedArray.slice(0, 12); + const encrypted = encryptedArray.slice(12); + + const decrypted = await window.crypto.subtle.decrypt( + { name: 'AES-GCM', iv: iv }, + this.sharedKey, + encrypted + ); + + return new Uint8Array(decrypted); + } + + // Public API methods + async createSession(): Promise { + try { + clipboardState.update(state => ({ ...state, isCreating: true })); + await this.generateKeyPair(); + await this.connectWebSocket(); + + const publicKeyBuffer = await this.exportPublicKey(); + const publicKeyArray = Array.from(new Uint8Array(publicKeyBuffer)); + + if (this.ws) { + this.ws.send(JSON.stringify({ + type: 'create_session', + publicKey: publicKeyArray + })); + } + } catch (error) { + console.error('Error creating session:', error); + clipboardState.update(state => ({ ...state, isCreating: false })); + } + } + + async joinSession(joinCode: string): Promise { + try { + console.log('Starting join session process...'); + clipboardState.update(state => ({ ...state, isJoining: true })); + + await this.generateKeyPair(); + await this.connectWebSocket(); + + const publicKeyBuffer = await this.exportPublicKey(); + const publicKeyArray = Array.from(new Uint8Array(publicKeyBuffer)); + + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + const message = { + type: 'join_session', + sessionId: joinCode, + publicKey: publicKeyArray + }; + console.log('Sending join message:', message); + this.ws.send(JSON.stringify(message)); + } else { + console.error('WebSocket not ready'); + clipboardState.update(state => ({ ...state, isJoining: false })); + } + } catch (error) { + console.error('Error joining session:', error); + clipboardState.update(state => ({ ...state, isJoining: false })); + } + } + + async generateQRCode(sessionId: string): Promise { + try { + if (typeof window !== 'undefined' && sessionId) { + let origin = window.location.origin; + if (origin.includes('localhost') || origin.includes('127.0.0.1')) { + origin = origin.replace(/localhost:\d+|127\.0\.0\.1:\d+/, '192.168.1.12:5173'); + } + + const url = `${origin}/clipboard?session=${sessionId}`; + const qrCodeUrl = await QRCode.toDataURL(url, { + color: { dark: '#000000', light: '#ffffff' } + }); + + clipboardState.update(state => ({ ...state, qrCodeUrl })); + console.log('QR Code generated'); + } + } catch (error) { + console.error('QR Code generation failed:', error); + } + } + + shareSession(sessionId: string): void { + if (typeof window !== 'undefined' && sessionId) { + let origin = window.location.origin; + if (origin.includes('localhost') || origin.includes('127.0.0.1')) { + origin = origin.replace(/localhost:\d+|127\.0\.0\.1:\d+/, '192.168.1.12:5173'); + } + + const url = `${origin}/clipboard?session=${sessionId}`; + navigator.clipboard.writeText(url); + } + } + + cleanup(): void { + if (this.dataChannel) { + this.dataChannel.close(); + this.dataChannel = null; + } + if (this.peerConnection) { + this.peerConnection.close(); + this.peerConnection = null; + } + if (this.ws) { + this.ws.close(); + this.ws = null; + } + if (this.statusInterval) { + clearInterval(this.statusInterval); + } + + clipboardState.update(state => ({ + ...state, + sessionId: '', + isConnected: false, + peerConnected: false, + qrCodeUrl: '' + })); + + this.sharedKey = null; + this.remotePublicKey = null; + this.clearStoredSession(); + } + + // WebSocket message handler + private async handleWebSocketMessage(message: any): Promise { + console.log('Handling WebSocket message:', message.type); + + switch (message.type) { + case 'session_created': + clipboardState.update(state => ({ + ...state, + sessionId: message.sessionId, + isCreating: false, + isCreator: true + })); + await this.generateQRCode(message.sessionId); + this.saveSession(message.sessionId, true); + break; + + case 'session_joined': + console.log('Session joined successfully, setting up WebRTC...'); + await this.importRemotePublicKey(message.publicKey); + await this.deriveSharedKey(); + clipboardState.update(state => ({ ...state, isJoining: false })); + await this.setupWebRTC(false); + break; + + case 'peer_joined': + console.log('Peer joined, setting up WebRTC...'); + await this.importRemotePublicKey(message.publicKey); + await this.deriveSharedKey(); + + // Get current state to check if this is the creator + let currentState: any = {}; + const unsubscribe = clipboardState.subscribe(s => currentState = s); + unsubscribe(); + + if (currentState.isCreator) { + await this.setupWebRTC(true); + } else { + await this.setupWebRTC(false); + } + break; + + case 'offer': + await this.handleOffer(message.offer); + break; + + case 'answer': + await this.handleAnswer(message.answer); + break; + + case 'ice_candidate': + await this.handleIceCandidate(message.candidate); + break; + + case 'error': + console.error('Server error:', message); + clipboardState.update(state => ({ + ...state, + isCreating: false, + isJoining: false + })); + break; + } + } + + // WebRTC setup and handlers + private async setupWebRTC(isInitiator: boolean): Promise { + try { + this.peerConnection = new RTCPeerConnection({ + iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] + }); + + // Update state with peer connection + clipboardState.update(state => ({ + ...state, + peerConnection: this.peerConnection + })); + + this.peerConnection.onicecandidate = (event) => { + if (event.candidate && this.ws) { + this.ws.send(JSON.stringify({ + type: 'ice_candidate', + candidate: event.candidate + })); + } + }; + + this.peerConnection.onconnectionstatechange = () => { + const state = this.peerConnection?.connectionState; + console.log('๐Ÿ”— Peer connection state changed:', state); + + 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'); + clipboardState.update(state => ({ ...state, peerConnected: false })); + } + }; + + if (isInitiator) { + this.dataChannel = this.peerConnection.createDataChannel('files', { + ordered: true + }); + this.setupDataChannel(); + + const offer = await this.peerConnection.createOffer(); + await this.peerConnection.setLocalDescription(offer); + + if (this.ws) { + this.ws.send(JSON.stringify({ + type: 'offer', + offer + })); + } + } else { + this.peerConnection.ondatachannel = (event) => { + this.dataChannel = event.channel; + this.setupDataChannel(); + }; + } + } catch (error) { + console.error('Error setting up WebRTC:', error); + } + } + + private setupDataChannel(): void { + if (!this.dataChannel) return; + + // Update state with data channel + clipboardState.update(state => ({ + ...state, + dataChannel: this.dataChannel + })); + + this.dataChannel.onopen = () => { + console.log('๐ŸŽ‰ Data channel opened!'); + clipboardState.update(state => ({ ...state, peerConnected: true })); + }; + + this.dataChannel.onclose = () => { + console.log('โŒ Data channel closed'); + clipboardState.update(state => ({ ...state, peerConnected: false })); + }; + + this.dataChannel.onerror = (error) => { + console.error('Data channel error:', error); + }; this.dataChannel.onmessage = async (event) => { + console.log('๐Ÿ“จ Data channel message received:', event.data); + try { + const data = JSON.parse(event.data); + console.log('๐Ÿ“ฆ Parsed data:', data.type, data); + await this.handleDataChannelMessage(data); + } catch (error) { + console.error('Error handling data channel message:', error); + } + }; + } + + private async handleOffer(offer: RTCSessionDescriptionInit): Promise { + try { + if (this.peerConnection) { + await this.peerConnection.setRemoteDescription(offer); + const answer = await this.peerConnection.createAnswer(); + await this.peerConnection.setLocalDescription(answer); + + if (this.ws) { + this.ws.send(JSON.stringify({ + type: 'answer', + answer + })); + } + } + } catch (error) { + console.error('Error handling offer:', error); + } + } + + private async handleAnswer(answer: RTCSessionDescriptionInit): Promise { + try { + if (this.peerConnection) { + await this.peerConnection.setRemoteDescription(answer); + } + } catch (error) { + console.error('Error handling answer:', error); + } + } + + private async handleIceCandidate(candidate: RTCIceCandidateInit): Promise { + try { + if (this.peerConnection) { + await this.peerConnection.addIceCandidate(candidate); + } + } catch (error) { + console.error('Error handling ICE candidate:', error); + } + } private async handleDataChannelMessage(data: any): Promise { + console.log('๐Ÿ” Handling data channel message type:', data.type); + // Handle different types of data channel messages + switch (data.type) { case 'text': + // Convert array back to ArrayBuffer for decryption + const encryptedBuffer = new Uint8Array(data.content).buffer; + const decryptedText = await this.decryptData(encryptedBuffer); + clipboardState.update(state => ({ + ...state, + receivedText: decryptedText, + activeTab: 'text' // ่‡ชๅŠจๅˆ‡ๆขๅˆฐๆ–‡ๆœฌๅˆ†ไบซๆ ‡็ญพ + })); + console.log('Text received successfully, switched to text tab'); + break; + case 'file_start': + // Start receiving a new file + this.currentReceivingFile = { + name: data.name, + size: data.size, + type: data.mimeType || data.type, + chunks: [], + receivedSize: 0 + }; + clipboardState.update(state => ({ + ...state, + receivingFiles: true, + transferProgress: 0, + activeTab: 'files' // ่‡ชๅŠจๅˆ‡ๆขๅˆฐๆ–‡ไปถไผ ่พ“ๆ ‡็ญพ + })); + console.log('Started receiving file:', data.name, ', switched to files tab'); + break; + + case 'file_chunk': + if (this.currentReceivingFile) { + // Decrypt and store chunk + const encryptedData = new Uint8Array(data.data).buffer; + const decryptedChunk = await this.decryptBinaryData(encryptedData); + + this.currentReceivingFile.chunks.push(decryptedChunk); + this.currentReceivingFile.receivedSize += decryptedChunk.length; + + // Update progress + const progress = (this.currentReceivingFile.receivedSize / this.currentReceivingFile.size) * 100; + clipboardState.update(state => ({ + ...state, + transferProgress: Math.min(progress, 100) + })); + } + break; + + case 'file_end': + if (this.currentReceivingFile && this.currentReceivingFile.name === data.name) { + // Combine all chunks into a single file + const totalSize = this.currentReceivingFile.chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const combinedArray = new Uint8Array(totalSize); + let offset = 0; + + for (const chunk of this.currentReceivingFile.chunks) { + combinedArray.set(chunk, offset); + offset += chunk.length; + } + + const blob = new Blob([combinedArray], { type: this.currentReceivingFile.type }); + const fileItem: FileItem = { + name: this.currentReceivingFile.name, + size: this.currentReceivingFile.size, + type: this.currentReceivingFile.type, + blob: blob + }; + + // Add to received files + clipboardState.update(state => ({ + ...state, + receivedFiles: [...state.receivedFiles, fileItem], + receivingFiles: false, + transferProgress: 0 + })); + + console.log('File received successfully:', data.name); + this.currentReceivingFile = null; + } + break; + } + } + + // Public methods for sending data + async sendText(text: string): Promise { + if (!this.dataChannel || this.dataChannel.readyState !== 'open') { + console.error('Data channel not ready'); + return; + } + try { + console.log('๐Ÿ” Encrypting text:', text.substring(0, 20) + '...'); + const encryptedText = await this.encryptData(text); + // Convert ArrayBuffer to Array for JSON serialization + const encryptedArray = Array.from(new Uint8Array(encryptedText)); + const message = { + type: 'text', + content: encryptedArray + }; + + console.log('๐Ÿ“ค Sending message:', message.type, 'with content length:', message.content.length); + this.dataChannel.send(JSON.stringify(message)); + console.log('Text sent successfully'); + } catch (error) { + console.error('Error sending text:', error); + } + } + + async sendFiles(): Promise { + if (!this.dataChannel || this.dataChannel.readyState !== 'open') { + console.error('Data channel not ready'); + return; + } + + let currentFiles: File[] = []; + const unsubscribe = clipboardState.subscribe(state => { + currentFiles = state.files; + }); + unsubscribe(); + + if (currentFiles.length === 0) { + console.log('No files to send'); + return; + } + + try { + clipboardState.update(state => ({ ...state, sendingFiles: true, transferProgress: 0 })); + + for (let i = 0; i < currentFiles.length; i++) { + const file = currentFiles[i]; + await this.sendSingleFile(file); + + // Update progress + const progress = ((i + 1) / currentFiles.length) * 100; + clipboardState.update(state => ({ ...state, transferProgress: progress })); + } + + clipboardState.update(state => ({ ...state, sendingFiles: false, transferProgress: 0 })); + console.log('All files sent successfully'); + } catch (error) { + console.error('Error sending files:', error); + clipboardState.update(state => ({ ...state, sendingFiles: false, transferProgress: 0 })); + } + } + + private async sendSingleFile(file: File): Promise { + // Send file start message + const fileStartMessage = { + type: 'file_start', + name: file.name, + size: file.size, + mimeType: file.type + }; + + this.dataChannel!.send(JSON.stringify(fileStartMessage)); + + // Send file in chunks + const arrayBuffer = await file.arrayBuffer(); + const totalChunks = Math.ceil(arrayBuffer.byteLength / CHUNK_SIZE); + + for (let i = 0; i < totalChunks; i++) { + const start = i * CHUNK_SIZE; + const end = Math.min(start + CHUNK_SIZE, arrayBuffer.byteLength); + const chunk = arrayBuffer.slice(start, end); + const encryptedChunk = await this.encryptBinaryData(new Uint8Array(chunk)); + + const chunkMessage = { + type: 'file_chunk', + index: i, + data: Array.from(new Uint8Array(encryptedChunk)) + }; + + this.dataChannel!.send(JSON.stringify(chunkMessage)); + } + + // Send file end message + const fileEndMessage = { + type: 'file_end', + name: file.name + }; + + this.dataChannel!.send(JSON.stringify(fileEndMessage)); + } + + // Expose peer connection and data channel for debug panel + get debugInfo() { + return { + peerConnection: this.peerConnection, + dataChannel: this.dataChannel, + sharedKey: this.sharedKey ? 'Present' : 'Not available' + }; + } +} diff --git a/web/src/lib/clipboard/clipboard-manager.ts.broken b/web/src/lib/clipboard/clipboard-manager.ts.broken new file mode 100644 index 00000000..cf6eaf35 --- /dev/null +++ b/web/src/lib/clipboard/clipboard-manager.ts.broken @@ -0,0 +1,793 @@ +// WebRTC and WebSocket management composable +import { writable } from 'svelte/store'; +import { currentApiURL } from '$lib/api/api-url'; +import QRCode from 'qrcode'; + +// Types +export interface FileItem { + name: string; + size: number; + type: string; + blob: Blob; +} + +interface ReceivingFile { + name: string; + size: number; + type: string; + chunks: Uint8Array[]; + receivedSize: number; +} + +// Constants +const CHUNK_SIZE = 64 * 1024; // 64KB chunks for file transfer + +// Store for reactive state +export const clipboardState = writable({ + sessionId: '', + isConnected: false, + isCreating: false, + isJoining: false, + isCreator: false, + peerConnected: false, + qrCodeUrl: '', + activeTab: 'files' as 'files' | 'text', + files: [] as File[], + receivedFiles: [] as FileItem[], + textContent: '', + receivedText: '', + dragover: false, + sendingFiles: false, + receivingFiles: false, + transferProgress: 0, + dataChannel: null as RTCDataChannel | null, + peerConnection: null as RTCPeerConnection | null +}); + +export class ClipboardManager { + private ws: WebSocket | null = null; + private peerConnection: RTCPeerConnection | null = null; + private dataChannel: RTCDataChannel | null = null; + private keyPair: CryptoKeyPair | null = null; + private remotePublicKey: CryptoKey | null = null; + private sharedKey: CryptoKey | null = null; + private currentReceivingFile: ReceivingFile | null = null; + private statusInterval: ReturnType | null = null; + + constructor() { + this.loadStoredSession(); + this.startStatusCheck(); + } + + // Session persistence + private loadStoredSession(): void { + if (typeof window !== 'undefined') { + const stored = localStorage.getItem('clipboard_session'); + if (stored) { + try { + const sessionData = JSON.parse(stored); + const now = Date.now(); + + // Check if session is still valid (30 minute timeout) + if (sessionData.timestamp && (now - sessionData.timestamp) < 30 * 60 * 1000) { + clipboardState.update(state => ({ + ...state, + sessionId: sessionData.sessionId || '', + isCreator: sessionData.isCreator || false + })); + console.log('๐Ÿ“ Loaded stored session'); + return; + } else { + console.log('๐Ÿ• Stored session expired, clearing...'); + localStorage.removeItem('clipboard_session'); + } + } catch (error) { + console.error('โŒ Error loading stored session:', error); + localStorage.removeItem('clipboard_session'); + } + } + } + } + + private saveSession(sessionId: string, isCreator: boolean): void { + if (typeof window !== 'undefined' && sessionId) { + const sessionData = { + sessionId, + isCreator, + timestamp: Date.now() + }; + localStorage.setItem('clipboard_session', JSON.stringify(sessionData)); + console.log('๐Ÿ’พ Session saved to localStorage:', sessionData); + } + } + + private clearStoredSession(): void { + if (typeof window !== 'undefined') { + localStorage.removeItem('clipboard_session'); + console.log('๐Ÿ—‘๏ธ Stored session cleared'); + } + } + + private startStatusCheck(): void { + this.statusInterval = setInterval(() => { + if (this.dataChannel) { + clipboardState.update(state => { + const isNowConnected = this.dataChannel?.readyState === 'open'; + if (state.peerConnected !== isNowConnected) { + console.log('Data channel state changed:', isNowConnected); + } + return { ...state, peerConnected: isNowConnected }; + }); + } + }, 1000); + } + + // WebSocket management + private getWebSocketURL(): string { + if (typeof window === 'undefined') return 'ws://localhost:9000/ws'; + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + let host = window.location.host; + + // For mobile access, use the actual IP instead of localhost + if (host.includes('localhost') || host.includes('127.0.0.1')) { + host = '192.168.1.12:5173'; + } + + const wsUrl = `${protocol}//${host}/ws`; + console.log('Constructed WebSocket URL:', wsUrl); + return wsUrl; + } + + private async connectWebSocket(): Promise { + return new Promise((resolve, reject) => { + try { + const wsUrl = this.getWebSocketURL(); + console.log('Connecting to WebSocket:', wsUrl); + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + console.log('WebSocket connected'); + clipboardState.update(state => ({ ...state, isConnected: true })); + resolve(); + }; + + this.ws.onmessage = async (event) => { + try { + const message = JSON.parse(event.data); + console.log('WebSocket message:', message); + await this.handleWebSocketMessage(message); + } catch (error) { + console.error('Error handling WebSocket message:', error); + } + }; + + this.ws.onclose = () => { + console.log('WebSocket disconnected'); + clipboardState.update(state => ({ ...state, isConnected: false })); + }; + + this.ws.onerror = (error) => { + console.error('WebSocket error:', error); + clipboardState.update(state => ({ ...state, isConnected: false })); + reject(new Error('WebSocket connection failed')); + }; + } catch (error) { + reject(error); + } + }); + } + + // Encryption + private async generateKeyPair(): Promise { + this.keyPair = await window.crypto.subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, + false, + ['deriveKey'] + ); + } + + private async exportPublicKey(): Promise { + if (!this.keyPair) throw new Error('Key pair not generated'); + return await window.crypto.subtle.exportKey('raw', this.keyPair.publicKey); + } + + private async importRemotePublicKey(publicKeyArray: number[]): Promise { + const publicKeyBuffer = new Uint8Array(publicKeyArray).buffer; + this.remotePublicKey = await window.crypto.subtle.importKey( + 'raw', + publicKeyBuffer, + { name: 'ECDH', namedCurve: 'P-256' }, + false, + [] + ); + } + + private async deriveSharedKey(): Promise { + if (!this.keyPair || !this.remotePublicKey) throw new Error('Keys not available'); + + this.sharedKey = await window.crypto.subtle.deriveKey( + { name: 'ECDH', public: this.remotePublicKey }, + this.keyPair.privateKey, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); + } private async encryptData(data: string): Promise { + if (!this.sharedKey) throw new Error('Shared key not available'); + + const encoder = new TextEncoder(); + const dataBuffer = encoder.encode(data); + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + + const encrypted = await window.crypto.subtle.encrypt( + { name: 'AES-GCM', iv: iv }, + this.sharedKey, + dataBuffer + ); + + // Combine IV and encrypted data + const result = new Uint8Array(iv.length + encrypted.byteLength); + result.set(iv); + result.set(new Uint8Array(encrypted), iv.length); + + return result.buffer; + } + + private async encryptBinaryData(data: Uint8Array): Promise { + if (!this.sharedKey) throw new Error('Shared key not available'); + + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + + const encrypted = await window.crypto.subtle.encrypt( + { name: 'AES-GCM', iv: iv }, + this.sharedKey, + data + ); + + // Combine IV and encrypted data + const result = new Uint8Array(iv.length + encrypted.byteLength); + result.set(iv); + result.set(new Uint8Array(encrypted), iv.length); + + return result.buffer; + } private async decryptData(encryptedBuffer: ArrayBuffer): Promise { + if (!this.sharedKey) throw new Error('Shared key not available'); + + const encryptedArray = new Uint8Array(encryptedBuffer); + const iv = encryptedArray.slice(0, 12); + const encrypted = encryptedArray.slice(12); + + const decrypted = await window.crypto.subtle.decrypt( + { name: 'AES-GCM', iv: iv }, + this.sharedKey, + encrypted + ); + + const decoder = new TextDecoder(); + return decoder.decode(decrypted); + } + + private async decryptBinaryData(encryptedBuffer: ArrayBuffer): Promise { + if (!this.sharedKey) throw new Error('Shared key not available'); + + const encryptedArray = new Uint8Array(encryptedBuffer); + const iv = encryptedArray.slice(0, 12); + const encrypted = encryptedArray.slice(12); + + const decrypted = await window.crypto.subtle.decrypt( + { name: 'AES-GCM', iv: iv }, + this.sharedKey, + encrypted + ); + + return new Uint8Array(decrypted); + } + + // Public API methods + async createSession(): Promise { + try { + clipboardState.update(state => ({ ...state, isCreating: true })); + await this.generateKeyPair(); + await this.connectWebSocket(); + + const publicKeyBuffer = await this.exportPublicKey(); + const publicKeyArray = Array.from(new Uint8Array(publicKeyBuffer)); + + if (this.ws) { + this.ws.send(JSON.stringify({ + type: 'create_session', + publicKey: publicKeyArray + })); + } + } catch (error) { + console.error('Error creating session:', error); + clipboardState.update(state => ({ ...state, isCreating: false })); + } + } + + async joinSession(joinCode: string): Promise { + try { + console.log('Starting join session process...'); + clipboardState.update(state => ({ ...state, isJoining: true })); + + await this.generateKeyPair(); + await this.connectWebSocket(); + + const publicKeyBuffer = await this.exportPublicKey(); + const publicKeyArray = Array.from(new Uint8Array(publicKeyBuffer)); + + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + const message = { + type: 'join_session', + sessionId: joinCode, + publicKey: publicKeyArray + }; + console.log('Sending join message:', message); + this.ws.send(JSON.stringify(message)); + } else { + console.error('WebSocket not ready'); + clipboardState.update(state => ({ ...state, isJoining: false })); + } + } catch (error) { + console.error('Error joining session:', error); + clipboardState.update(state => ({ ...state, isJoining: false })); + } + } + + async generateQRCode(sessionId: string): Promise { + try { + if (typeof window !== 'undefined' && sessionId) { + let origin = window.location.origin; + if (origin.includes('localhost') || origin.includes('127.0.0.1')) { + origin = origin.replace(/localhost:\d+|127\.0\.0\.1:\d+/, '192.168.1.12:5173'); + } + + const url = `${origin}/clipboard?session=${sessionId}`; + const qrCodeUrl = await QRCode.toDataURL(url, { + color: { dark: '#000000', light: '#ffffff' } + }); + + clipboardState.update(state => ({ ...state, qrCodeUrl })); + console.log('QR Code generated'); + } + } catch (error) { + console.error('QR Code generation failed:', error); + } + } + + shareSession(sessionId: string): void { + if (typeof window !== 'undefined' && sessionId) { + let origin = window.location.origin; + if (origin.includes('localhost') || origin.includes('127.0.0.1')) { + origin = origin.replace(/localhost:\d+|127\.0\.0\.1:\d+/, '192.168.1.12:5173'); + } + + const url = `${origin}/clipboard?session=${sessionId}`; + navigator.clipboard.writeText(url); + } + } + + cleanup(): void { + if (this.dataChannel) { + this.dataChannel.close(); + this.dataChannel = null; + } + if (this.peerConnection) { + this.peerConnection.close(); + this.peerConnection = null; + } + if (this.ws) { + this.ws.close(); + this.ws = null; + } + if (this.statusInterval) { + clearInterval(this.statusInterval); + } + + clipboardState.update(state => ({ + ...state, + sessionId: '', + isConnected: false, + peerConnected: false, + qrCodeUrl: '' + })); + + this.sharedKey = null; + this.remotePublicKey = null; + this.clearStoredSession(); + } + + // WebSocket message handler + private async handleWebSocketMessage(message: any): Promise { + console.log('Handling WebSocket message:', message.type); + + switch (message.type) { + case 'session_created': + clipboardState.update(state => ({ + ...state, + sessionId: message.sessionId, + isCreating: false, + isCreator: true + })); + await this.generateQRCode(message.sessionId); + this.saveSession(message.sessionId, true); + break; + + case 'session_joined': + console.log('Session joined successfully, setting up WebRTC...'); + await this.importRemotePublicKey(message.publicKey); + await this.deriveSharedKey(); + clipboardState.update(state => ({ ...state, isJoining: false })); + await this.setupWebRTC(false); + break; + case 'peer_joined': + console.log('Peer joined, setting up WebRTC...'); + await this.importRemotePublicKey(message.publicKey); + await this.deriveSharedKey(); + + // Get current state to check if this is the creator + let currentState: any = {}; + const unsubscribe = clipboardState.subscribe(s => currentState = s); + unsubscribe(); + + if (currentState.isCreator) { + await this.setupWebRTC(true); + } else { + await this.setupWebRTC(false); + } + break; + + case 'offer': + await this.handleOffer(message.offer); + break; + + case 'answer': + await this.handleAnswer(message.answer); + break; + + case 'ice_candidate': + await this.handleIceCandidate(message.candidate); + break; + + case 'error': + console.error('Server error:', message); + clipboardState.update(state => ({ + ...state, + isCreating: false, + isJoining: false + })); + break; + } + } // WebRTC setup and handlers + private async setupWebRTC(isInitiator: boolean): Promise { + try { + this.peerConnection = new RTCPeerConnection({ + iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] + }); + + // Update state with peer connection + clipboardState.update(state => ({ + ...state, + peerConnection: this.peerConnection + })); + + this.peerConnection.onicecandidate = (event) => { + if (event.candidate && this.ws) { + this.ws.send(JSON.stringify({ + type: 'ice_candidate', + candidate: event.candidate + })); + } + }; + + this.peerConnection.onconnectionstatechange = () => { + const state = this.peerConnection?.connectionState; + console.log('๐Ÿ”— Peer connection state changed:', state); + + 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'); + clipboardState.update(state => ({ ...state, peerConnected: false })); + } + }; + + if (isInitiator) { + this.dataChannel = this.peerConnection.createDataChannel('files', { + ordered: true + }); + this.setupDataChannel(); + + const offer = await this.peerConnection.createOffer(); + await this.peerConnection.setLocalDescription(offer); + + if (this.ws) { + this.ws.send(JSON.stringify({ + type: 'offer', + offer + })); + } + } else { + this.peerConnection.ondatachannel = (event) => { + this.dataChannel = event.channel; + this.setupDataChannel(); + }; + } + } catch (error) { + console.error('Error setting up WebRTC:', error); + } + } + + private setupDataChannel(): void { + if (!this.dataChannel) return; + + // Update state with data channel + clipboardState.update(state => ({ + ...state, + dataChannel: this.dataChannel + })); + + this.dataChannel.onopen = () => { + console.log('๐ŸŽ‰ Data channel opened!'); + clipboardState.update(state => ({ ...state, peerConnected: true })); + }; + + this.dataChannel.onclose = () => { + console.log('โŒ Data channel closed'); + clipboardState.update(state => ({ ...state, peerConnected: false })); + }; + + this.dataChannel.onerror = (error) => { + console.error('Data channel error:', error); + }; + + this.dataChannel.onmessage = async (event) => { + try { + const data = JSON.parse(event.data); + await this.handleDataChannelMessage(data); + } catch (error) { + console.error('Error handling data channel message:', error); + } + }; + } + + private async handleOffer(offer: RTCSessionDescriptionInit): Promise { + try { + if (this.peerConnection) { + await this.peerConnection.setRemoteDescription(offer); + const answer = await this.peerConnection.createAnswer(); + await this.peerConnection.setLocalDescription(answer); + + if (this.ws) { + this.ws.send(JSON.stringify({ + type: 'answer', + answer + })); + } + } + } catch (error) { + console.error('Error handling offer:', error); + } + } + + private async handleAnswer(answer: RTCSessionDescriptionInit): Promise { + try { + if (this.peerConnection) { + await this.peerConnection.setRemoteDescription(answer); + } + } catch (error) { + console.error('Error handling answer:', error); + } + } + + private async handleIceCandidate(candidate: RTCIceCandidateInit): Promise { + try { + if (this.peerConnection) { + await this.peerConnection.addIceCandidate(candidate); + } + } catch (error) { + console.error('Error handling ICE candidate:', error); + } + } private async handleDataChannelMessage(data: any): Promise { + // Handle different types of data channel messages + switch (data.type) { + case 'text': + // Convert array back to ArrayBuffer for decryption + const encryptedBuffer = new Uint8Array(data.content).buffer; + const decryptedText = await this.decryptData(encryptedBuffer); + clipboardState.update(state => ({ + ...state, + receivedText: decryptedText + })); + console.log('Text received successfully'); + break; + + case 'file_start': + // Start receiving a new file + this.currentReceivingFile = { + name: data.name, + size: data.size, + type: data.mimeType || data.type, + chunks: [], + receivedSize: 0 + }; + clipboardState.update(state => ({ + ...state, + receivingFiles: true, + transferProgress: 0 + })); + console.log('Started receiving file:', data.name); + break; + + case 'file_chunk': + if (this.currentReceivingFile) { + // Decrypt and store chunk + const encryptedData = new Uint8Array(data.data).buffer; + const decryptedChunk = await this.decryptBinaryData(encryptedData); + + this.currentReceivingFile.chunks.push(decryptedChunk); + this.currentReceivingFile.receivedSize += decryptedChunk.length; + + // Update progress + const progress = (this.currentReceivingFile.receivedSize / this.currentReceivingFile.size) * 100; + clipboardState.update(state => ({ + ...state, + transferProgress: Math.min(progress, 100) + })); + } + break; + + case 'file_end': + if (this.currentReceivingFile && this.currentReceivingFile.name === data.name) { + // Combine all chunks into a single file + const totalSize = this.currentReceivingFile.chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const combinedArray = new Uint8Array(totalSize); + let offset = 0; + + for (const chunk of this.currentReceivingFile.chunks) { + combinedArray.set(chunk, offset); + offset += chunk.length; + } + + const blob = new Blob([combinedArray], { type: this.currentReceivingFile.type }); + const fileItem: FileItem = { + name: this.currentReceivingFile.name, + size: this.currentReceivingFile.size, + type: this.currentReceivingFile.type, + blob: blob + }; + + // Add to received files + clipboardState.update(state => ({ + ...state, + receivedFiles: [...state.receivedFiles, fileItem], + receivingFiles: false, + transferProgress: 0 + })); + + console.log('File received successfully:', data.name); + this.currentReceivingFile = null; + } + break; + } + } + + // Public methods for sending data async sendText(text: string): Promise { + if (!this.dataChannel || this.dataChannel.readyState !== 'open') { + console.error('Data channel not ready'); + return; + } + + try { + const encryptedText = await this.encryptData(text); + // Convert ArrayBuffer to Array for JSON serialization + const encryptedArray = Array.from(new Uint8Array(encryptedText)); + const message = { + type: 'text', + content: encryptedArray + }; + + this.dataChannel.send(JSON.stringify(message)); + console.log('Text sent successfully'); + } catch (error) { + console.error('Error sending text:', error); + } + } + + async sendFiles(): Promise { + if (!this.dataChannel || this.dataChannel.readyState !== 'open') { + console.error('Data channel not ready'); + return; + } + + let currentFiles: File[] = []; + const unsubscribe = clipboardState.subscribe(state => { + currentFiles = state.files; + }); + unsubscribe(); + + if (currentFiles.length === 0) { + console.log('No files to send'); + return; + } + + try { + clipboardState.update(state => ({ ...state, sendingFiles: true, transferProgress: 0 })); + + for (let i = 0; i < currentFiles.length; i++) { + const file = currentFiles[i]; + await this.sendSingleFile(file); + + // Update progress + const progress = ((i + 1) / currentFiles.length) * 100; + clipboardState.update(state => ({ ...state, transferProgress: progress })); + } + + // Clear sent files and reset state + clipboardState.update(state => ({ + ...state, + files: [], + sendingFiles: false, + transferProgress: 0 + })); + + console.log('All files sent successfully'); + } catch (error) { + console.error('Error sending files:', error); + clipboardState.update(state => ({ ...state, sendingFiles: false })); + } + } + private async sendSingleFile(file: File): Promise { + const CHUNK_SIZE = 64 * 1024; // 64KB chunks + + // Send file start message + const fileStartMessage = { + type: 'file_start', + name: file.name, + size: file.size, + mimeType: file.type + }; + this.dataChannel!.send(JSON.stringify(fileStartMessage)); + + // Send file in chunks + const arrayBuffer = await file.arrayBuffer(); + const totalChunks = Math.ceil(arrayBuffer.byteLength / CHUNK_SIZE); + + for (let i = 0; i < totalChunks; i++) { + const start = i * CHUNK_SIZE; + const end = Math.min(start + CHUNK_SIZE, arrayBuffer.byteLength); + const chunk = arrayBuffer.slice(start, end); + + // Encrypt chunk (binary data) + const encryptedChunk = await this.encryptBinaryData(new Uint8Array(chunk)); + + const chunkMessage = { + type: 'file_chunk', + index: i, + data: Array.from(new Uint8Array(encryptedChunk)) + }; + + this.dataChannel!.send(JSON.stringify(chunkMessage)); + + // Small delay to prevent overwhelming the data channel + await new Promise(resolve => setTimeout(resolve, 10)); + } + + // Send file end message + const fileEndMessage = { + type: 'file_end', + name: file.name + }; + this.dataChannel!.send(JSON.stringify(fileEndMessage)); + } + + // Expose peer connection and data channel for debug panel + get debugInfo() { + return { + peerConnection: this.peerConnection, + dataChannel: this.dataChannel + }; + } +} diff --git a/web/src/routes/clipboard/+page.svelte b/web/src/routes/clipboard/+page.svelte index 5ebd9aa0..b08cd48a 100644 --- a/web/src/routes/clipboard/+page.svelte +++ b/web/src/routes/clipboard/+page.svelte @@ -5,15 +5,15 @@ import ActionButton from '$components/buttons/ActionButton.svelte'; import QRCode from 'qrcode'; import { currentApiURL } from '$lib/api/api-url'; - - // Types - interface FileItem { - name: string; - size: number; - type: string; - blob: Blob; - } - + + // Import clipboard components + import SessionManager from '$components/clipboard/SessionManager.svelte'; + import TabNavigation from '$components/clipboard/TabNavigation.svelte'; + import FileTransfer from '$components/clipboard/FileTransfer.svelte'; + import TextSharing from '$components/clipboard/TextSharing.svelte'; + import DebugPanel from '$components/clipboard/DebugPanel.svelte'; + // Import clipboard manager + import { ClipboardManager, clipboardState, type FileItem } from '$lib/clipboard/clipboard-manager'; // Types interface ReceivingFile { name: string; size: number; @@ -25,7 +25,7 @@ // Constants const CHUNK_SIZE = 64 * 1024; // 64KB chunks for file transfer - // State variables + // State variables - bound to clipboard manager let sessionId = ''; let joinCode = ''; let isConnected = false; @@ -38,904 +38,86 @@ // Navigation state let activeTab: 'files' | 'text' = 'files'; - // WebSocket and WebRTC - let ws: WebSocket | null = null; - let peerConnection: RTCPeerConnection | null = null; - let dataChannel: RTCDataChannel | null = null; - - // Encryption - let keyPair: CryptoKeyPair | null = null; - let remotePublicKey: CryptoKey | null = null; - let sharedKey: CryptoKey | null = null; // File transfer + // File transfer state let files: File[] = []; let receivedFiles: FileItem[] = []; let textContent = ''; let receivedText = ''; let dragover = false; - let sendingFiles = false; - let receivingFiles = false; + let sendingFiles = false; let receivingFiles = false; let transferProgress = 0; let currentReceivingFile: ReceivingFile | null = null; - let fileInput: HTMLInputElement; + let dataChannel: RTCDataChannel | null = null; + let peerConnection: RTCPeerConnection | null = null; - // Session persistence - let storedSessionId = ''; - let storedIsCreator = false; - - // Load stored session data - function loadStoredSession(): void { - if (typeof window !== 'undefined') { - const stored = localStorage.getItem('clipboard_session'); - if (stored) { - try { - const sessionData = JSON.parse(stored); - const now = Date.now(); - - // Check if session is still valid (30 minute timeout) - if (sessionData.timestamp && (now - sessionData.timestamp) < 30 * 60 * 1000) { - storedSessionId = sessionData.sessionId || ''; - storedIsCreator = sessionData.isCreator || false; - console.log('๐Ÿ“ Loaded stored session:', { - sessionId: storedSessionId, - isCreator: storedIsCreator, - age: Math.round((now - sessionData.timestamp) / 1000) + 's' - }); - return; - } else { - console.log('๐Ÿ• Stored session expired, clearing...'); - localStorage.removeItem('clipboard_session'); - } - } catch (error) { - console.error('โŒ Error loading stored session:', error); - localStorage.removeItem('clipboard_session'); - } - } - } - - storedSessionId = ''; - storedIsCreator = false; - } - - // Save session data to localStorage - function saveSession(sessionId: string, isCreator: boolean): void { - if (typeof window !== 'undefined' && sessionId) { - const sessionData = { - sessionId, - isCreator, - timestamp: Date.now() - }; - localStorage.setItem('clipboard_session', JSON.stringify(sessionData)); - console.log('๐Ÿ’พ Session saved to localStorage:', sessionData); - } - } - - // Clear stored session - function clearStoredSession(): void { - if (typeof window !== 'undefined') { - localStorage.removeItem('clipboard_session'); - console.log('๐Ÿ—‘๏ธ Stored session cleared'); - } - storedSessionId = ''; - storedIsCreator = false; - } - - // Attempt to reconnect to stored session - async function reconnectToStoredSession(): Promise { - if (!storedSessionId) return false; - - try { - console.log('๐Ÿ”„ Attempting to reconnect to stored session:', storedSessionId); - - await generateKeyPair(); - await connectWebSocket(); - - const publicKeyBuffer = await exportPublicKey(); - const publicKeyArray = Array.from(new Uint8Array(publicKeyBuffer)); - - if (ws) { - if (storedIsCreator) { - // Reconnect as creator - ws.send(JSON.stringify({ - type: 'create_session', - existingSessionId: storedSessionId, - publicKey: publicKeyArray - })); - } else { - // Reconnect as joiner - ws.send(JSON.stringify({ - type: 'join_session', - sessionId: storedSessionId, - publicKey: publicKeyArray - })); - } - return true; - } - } catch (error) { - console.error('โŒ Error reconnecting to stored session:', error); - clearStoredSession(); - } - - return false; - } - - // Lifecycle functions - let statusInterval: ReturnType | null = null; - - onMount(async () => { - // Check for session parameter in URL - if (typeof window !== 'undefined') { - const urlParams = new URLSearchParams(window.location.search); - const sessionParam = urlParams.get('session'); - if (sessionParam) { - joinCode = sessionParam; - await joinSession(); - } else { - // Try to reconnect to stored session if no session param - loadStoredSession(); - if (storedSessionId) { - console.log('๐Ÿ”„ Reconnecting to stored session on mount:', storedSessionId); - await reconnectToStoredSession(); - } - } - } - - // Start periodic connection status check - statusInterval = setInterval(() => { - if (dataChannel) { - const wasConnected = peerConnected; - const isNowConnected = dataChannel.readyState === 'open'; - - if (wasConnected !== isNowConnected) { - console.log('Data channel state changed:', { - was: wasConnected, - now: isNowConnected, - readyState: dataChannel.readyState - }); peerConnected = isNowConnected; - } - } - }, 1000); - }); - - onDestroy(() => { - if (statusInterval) { - clearInterval(statusInterval); - } - cleanup(); - }); - - // Helper functions - function getWebSocketURL(): string { - if (typeof window === 'undefined') return 'ws://localhost:9000/ws'; - - const apiUrl = currentApiURL(); - console.log('Current API URL:', apiUrl); - - // For local development with SSL, use the Vite proxy - if (typeof window !== 'undefined') { - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - let host = window.location.host; - - // For mobile access, use the actual IP instead of localhost - if (host.includes('localhost') || host.includes('127.0.0.1')) { - host = '192.168.1.12:5173'; - } - - const wsUrl = `${protocol}//${host}/ws`; - console.log('Constructed WebSocket URL:', wsUrl); - return wsUrl; - } - - // Fallback for server-side rendering - return 'ws://192.168.1.12:9000/ws'; - } - - async function generateKeyPair(): Promise { - keyPair = await window.crypto.subtle.generateKey( - { name: 'ECDH', namedCurve: 'P-256' }, - false, - ['deriveKey'] - ); } - - async function exportPublicKey(): Promise { - if (!keyPair) throw new Error('Key pair not generated'); - return await window.crypto.subtle.exportKey('raw', keyPair.publicKey); - } - - async function importRemotePublicKey(publicKeyArray: number[]): Promise { - const publicKeyBuffer = new Uint8Array(publicKeyArray).buffer; - remotePublicKey = await window.crypto.subtle.importKey( - 'raw', - publicKeyBuffer, - { name: 'ECDH', namedCurve: 'P-256' }, - false, - [] - ); - } - - async function deriveSharedKey(): Promise { - if (!keyPair || !remotePublicKey) throw new Error('Keys not available'); - - sharedKey = await window.crypto.subtle.deriveKey( - { name: 'ECDH', public: remotePublicKey }, - keyPair.privateKey, - { name: 'AES-GCM', length: 256 }, - false, - ['encrypt', 'decrypt'] - ); - } - - async function encryptData(data: string): Promise { - if (!sharedKey) throw new Error('Shared key not available'); - - const encoder = new TextEncoder(); - const dataBuffer = encoder.encode(data); - const iv = window.crypto.getRandomValues(new Uint8Array(12)); - - const encrypted = await window.crypto.subtle.encrypt( - { name: 'AES-GCM', iv: iv }, - sharedKey, - dataBuffer - ); - - // Combine IV and encrypted data - const result = new Uint8Array(iv.length + encrypted.byteLength); - result.set(iv); - result.set(new Uint8Array(encrypted), iv.length); - - return result.buffer; - } - - async function decryptData(encryptedBuffer: ArrayBuffer): Promise { - if (!sharedKey) throw new Error('Shared key not available'); - - const encryptedArray = new Uint8Array(encryptedBuffer); - const iv = encryptedArray.slice(0, 12); - const encrypted = encryptedArray.slice(12); - - const decrypted = await window.crypto.subtle.decrypt( - { name: 'AES-GCM', iv: iv }, - sharedKey, - encrypted - ); - - const decoder = new TextDecoder(); - return decoder.decode(decrypted); - } - - async function connectWebSocket(): Promise { - return new Promise((resolve, reject) => { - try { - const wsUrl = getWebSocketURL(); - console.log('Connecting to WebSocket:', wsUrl); - ws = new WebSocket(wsUrl); - - ws.onopen = () => { - console.log('WebSocket connected'); - isConnected = true; - resolve(); - }; - - ws.onmessage = async (event) => { - try { - const message = JSON.parse(event.data); - console.log('WebSocket message:', message); - await handleWebSocketMessage(message); - } catch (error) { - console.error('Error handling WebSocket message:', error); - } - }; - - ws.onclose = () => { - console.log('WebSocket disconnected'); - isConnected = false; - }; - ws.onerror = (error) => { - console.error('WebSocket error:', error); - console.error('WebSocket error details:', { - readyState: ws?.readyState, - url: wsUrl, - error: error - }); - isConnected = false; - alert('WebSocket connection failed. Check console for details.'); - reject(new Error('WebSocket connection failed')); - }; - } catch (error) { - reject(error); - } - }); - } async function handleWebSocketMessage(message: any): Promise { - console.log('Handling WebSocket message:', message.type, message); - - switch (message.type) { - case 'session_created': - sessionId = message.sessionId; - isCreating = false; - isCreator = true; - console.log('Session created:', sessionId); - await generateQRCode(); - saveSession(sessionId, isCreator); // Save session on create - break; - - case 'session_reconnected': - sessionId = message.sessionId; - isCreating = false; - isCreator = true; - console.log('Session reconnected:', sessionId); - await generateQRCode(); - saveSession(sessionId, isCreator); // Update saved session on reconnect - break; - - case 'session_joined': - console.log('Session joined successfully, setting up WebRTC...'); - await importRemotePublicKey(message.publicKey); - await deriveSharedKey(); - isJoining = false; - console.log('About to setup WebRTC as joiner (offer=false)'); - await setupWebRTC(false); - break; - - case 'waiting_for_creator': - console.log('Waiting for creator to reconnect...'); - isJoining = false; - // ๅฏไปฅๆ˜พ็คบ็ญ‰ๅพ…็Šถๆ€็ป™็”จๆˆท - break; - - case 'creator_reconnected': - console.log('Creator reconnected, setting up WebRTC...'); - await importRemotePublicKey(message.publicKey); - await deriveSharedKey(); - console.log('About to setup WebRTC as joiner (offer=false)'); - await setupWebRTC(false); - break; - - case 'peer_already_joined': - console.log('Peer already joined, setting up WebRTC...'); - await importRemotePublicKey(message.publicKey); - await deriveSharedKey(); - console.log('Setting up WebRTC as creator (offer=true)'); - await setupWebRTC(true); - break; - - case 'peer_joined': - console.log('Peer joined, setting up WebRTC...'); - await importRemotePublicKey(message.publicKey); - await deriveSharedKey(); - if (isCreator) { - console.log('Setting up WebRTC as creator (offer=true)'); - await setupWebRTC(true); - } else { - console.log('Setting up WebRTC as joiner (offer=false)'); - await setupWebRTC(false); - } - break; - - case 'peer_disconnected': - console.log('Peer disconnected, waiting for reconnection...'); - peerConnected = false; - // ๅฏไปฅๆ˜พ็คบ็ญ‰ๅพ…้‡่ฟž็Šถๆ€ - break; - - case 'offer': - await handleOffer(message.offer); - break; - - case 'answer': - await handleAnswer(message.answer); - break; - - case 'ice_candidate': - await handleIceCandidate(message.candidate); - break; - case 'error': - console.error('Server error:', message); - alert(`WebSocket error: ${message.message || message.error || 'Unknown error'}`); - isCreating = false; - isJoining = false; - break; - - default: - console.warn('Unknown message type:', message); - break; - } - } async function createSession(): Promise { - try { - isCreating = true; - await generateKeyPair(); - await connectWebSocket(); - - const publicKeyBuffer = await exportPublicKey(); - const publicKeyArray = Array.from(new Uint8Array(publicKeyBuffer)); - - if (ws) { - ws.send(JSON.stringify({ - type: 'create_session', - publicKey: publicKeyArray - })); - } - } catch (error) { - console.error('Error creating session:', error); - isCreating = false; - } } async function joinSession(): Promise { - try { - console.log('Starting join session process...', { joinCode, hasWebSocket: !!ws }); - isJoining = true; - - console.log('Generating key pair...'); - await generateKeyPair(); - console.log('Key pair generated successfully'); - - console.log('Connecting to WebSocket...'); - await connectWebSocket(); - console.log('WebSocket connected, sending join request...'); - - const publicKeyBuffer = await exportPublicKey(); - const publicKeyArray = Array.from(new Uint8Array(publicKeyBuffer)); - console.log('Public key prepared, array length:', publicKeyArray.length); - - if (ws && ws.readyState === WebSocket.OPEN) { - const message = { - type: 'join_session', - sessionId: joinCode, - publicKey: publicKeyArray - }; - console.log('Sending join message:', message); - ws.send(JSON.stringify(message)); - console.log('Join message sent successfully'); - } else { - console.error('WebSocket not ready:', { ws: !!ws, readyState: ws?.readyState }); - alert('WebSocket connection failed. Please try again.'); - isJoining = false; - } } catch (error) { - console.error('Error joining session:', error); - alert(`Failed to join session: ${error instanceof Error ? error.message : 'Unknown error'}`); - isJoining = false; - } - } async function setupWebRTC(shouldCreateOffer: boolean): Promise { - console.log('Setting up WebRTC...', { shouldCreateOffer, isCreator }); - - // Clean up any existing connection - if (peerConnection) { - console.log('๐Ÿงน Cleaning up existing peer connection'); - peerConnection.close(); - } - - peerConnection = new RTCPeerConnection({ - iceServers: [ - { urls: 'stun:stun.l.google.com:19302' }, - { urls: 'stun:stun1.l.google.com:19302' }, - { urls: 'stun:stun2.l.google.com:19302' } - ], - iceCandidatePoolSize: 10 + // Clipboard manager instance + let clipboardManager: ClipboardManager; // Subscribe to clipboard state + $: if (clipboardManager) { + clipboardState.subscribe(state => { + sessionId = state.sessionId; + isConnected = state.isConnected; + isCreating = state.isCreating; + isJoining = state.isJoining; + isCreator = state.isCreator; + peerConnected = state.peerConnected; + qrCodeUrl = state.qrCodeUrl; + activeTab = state.activeTab; + files = state.files; + receivedFiles = state.receivedFiles; + // Don't overwrite textContent - it's managed by the TextSharing component binding + // textContent = state.textContent; + receivedText = state.receivedText; + dragover = state.dragover; + sendingFiles = state.sendingFiles; + receivingFiles = state.receivingFiles; + transferProgress = state.transferProgress; + dataChannel = state.dataChannel; + peerConnection = state.peerConnection; }); + } - // Enhanced connection state tracking - peerConnection.onconnectionstatechange = () => { - const state = peerConnection?.connectionState; - console.log('๐Ÿ”„ Peer connection state changed:', state); - - if (state === 'connected') { - console.log('๐ŸŽ‰ Peer connection established successfully!'); - } else if (state === 'failed') { - console.error('โŒ Peer connection failed'); - peerConnected = false; - // Try to restart connection after a delay - setTimeout(() => { - if (isCreator && state === 'failed') { - console.log('๐Ÿ”„ Attempting to restart failed connection...'); - setupWebRTC(true); - } - }, 2000); - } else if (state === 'disconnected') { - console.warn('โš ๏ธ Peer connection disconnected'); - peerConnected = false; - } - }; + // Event handlers for components + function handleCreateSession() { + clipboardManager?.createSession(); + } - peerConnection.oniceconnectionstatechange = () => { - const iceState = peerConnection?.iceConnectionState; - console.log('๐ŸงŠ ICE connection state changed:', iceState); - - if (iceState === 'connected' || iceState === 'completed') { - console.log('๐ŸŽ‰ ICE connection established!'); - } else if (iceState === 'failed') { - console.error('โŒ ICE connection failed - checking data channel...'); - if (dataChannel && dataChannel.readyState !== 'open') { - console.log('๐Ÿ”„ ICE failed but data channel not open, will restart'); - setTimeout(() => { - if (isCreator && iceState === 'failed') { - console.log('๐Ÿ”„ Restarting WebRTC due to ICE failure...'); - setupWebRTC(true); - } - }, 3000); - } - } else if (iceState === 'disconnected') { - console.warn('โš ๏ธ ICE connection disconnected'); - } - }; - - peerConnection.onicegatheringstatechange = () => { - const gatheringState = peerConnection?.iceGatheringState; - console.log('๐ŸงŠ ICE gathering state changed:', gatheringState); - - if (gatheringState === 'complete') { - console.log('โœ… ICE gathering completed'); - } - }; - - peerConnection.onicecandidate = (event: RTCPeerConnectionIceEvent) => { - if (event.candidate) { - console.log('๐ŸงŠ New ICE candidate generated:', { - candidate: event.candidate.candidate, - sdpMid: event.candidate.sdpMid, - sdpMLineIndex: event.candidate.sdpMLineIndex, - type: event.candidate.type, - protocol: event.candidate.protocol - }); - - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ - type: 'ice_candidate', - candidate: event.candidate - })); - console.log('๐Ÿ“ค ICE candidate sent via WebSocket'); - } else { - console.error('โŒ WebSocket not ready when sending ICE candidate'); - } - } else { - console.log('๐Ÿ ICE gathering completed (null candidate received)'); - } - }; - - peerConnection.onsignalingstatechange = () => { - const signalingState = peerConnection?.signalingState; - console.log('๐Ÿ“ก Signaling state changed:', signalingState); - - if (signalingState === 'stable') { - console.log('โœ… Signaling negotiation completed successfully'); - } else if (signalingState === 'closed') { - console.log('๐Ÿ”’ Peer connection signaling closed'); - } - }; - - peerConnection.ondatachannel = (event: RTCDataChannelEvent) => { - console.log('๐Ÿ“ฅ Data channel received from remote peer:', { - label: event.channel.label, - readyState: event.channel.readyState, - ordered: event.channel.ordered, - protocol: event.channel.protocol - }); - dataChannel = event.channel; - setupDataChannel(dataChannel); - }; - - if (shouldCreateOffer) { - console.log('๐Ÿ‘‘ Creating data channel and offer (as creator)...'); - dataChannel = peerConnection.createDataChannel('fileTransfer', { - ordered: true, - maxRetransmits: 3 - }); - console.log('๐Ÿ“ก Data channel created:', { - label: dataChannel.label, - readyState: dataChannel.readyState, - ordered: dataChannel.ordered - }); - setupDataChannel(dataChannel); - - try { - console.log('โณ Creating WebRTC offer...'); - const offer = await peerConnection.createOffer(); - console.log('๐Ÿ“ Offer created, setting as local description...'); - await peerConnection.setLocalDescription(offer); - console.log('โœ… Local description set successfully'); - console.log('Offer details:', { - type: offer.type, - sdpPreview: offer.sdp?.substring(0, 200) + '...' - }); - - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'offer', offer: offer })); - console.log('๐Ÿ“ค Offer sent via WebSocket'); - } else { - console.error('โŒ WebSocket not ready when sending offer'); - } - - // Enhanced timeout for connection establishment - setTimeout(() => { - if (dataChannel && dataChannel.readyState !== 'open') { - console.warn('โš ๏ธ Data channel still not open after 30 seconds'); - console.log('Current connection states:', { - dataChannelState: dataChannel.readyState, - peerConnectionState: peerConnection?.connectionState, - iceConnectionState: peerConnection?.iceConnectionState, - iceGatheringState: peerConnection?.iceGatheringState, - signalingState: peerConnection?.signalingState - }); - - // Auto-restart if stuck - console.log('๐Ÿ”„ Auto-restarting WebRTC connection due to timeout...'); - setupWebRTC(true); - } - }, 30000); - - } catch (error) { - console.error('โŒ Error creating offer:', error); - } - } else { - console.log('๐Ÿ‘ฅ Waiting for data channel from remote peer (as joiner)...'); - } - }function setupDataChannel(channel: RTCDataChannel): void { - console.log('Setting up data channel:', { - readyState: channel.readyState, - label: channel.label, - ordered: channel.ordered, - protocol: channel.protocol - }); - - channel.onopen = () => { - console.log('๐ŸŽ‰ Data channel opened successfully!'); - console.log('Data channel final state:', { - readyState: channel.readyState, - bufferedAmount: channel.bufferedAmount, - maxPacketLifeTime: channel.maxPacketLifeTime, - maxRetransmits: channel.maxRetransmits - }); - peerConnected = true; - console.log('โœ… peerConnected set to true, UI should update'); - - // Force reactive update - peerConnected = peerConnected; - }; - - channel.onclose = () => { - console.log('โŒ Data channel closed'); - console.log('Data channel close state:', { - readyState: channel.readyState, - peerConnectionState: peerConnection?.connectionState - }); - peerConnected = false; - }; - - channel.onerror = (error) => { - console.error('โŒ Data channel error:', error); - console.error('Data channel error details:', { - readyState: channel.readyState, - error: error, - peerConnectionState: peerConnection?.connectionState, - iceConnectionState: peerConnection?.iceConnectionState - }); - }; - - channel.onmessage = async (event) => { - try { - console.log('๐Ÿ“ฉ Received data channel message, size:', event.data.byteLength || event.data.length); - const encryptedData = event.data; - const decryptedMessage = await decryptData(encryptedData); - const message = JSON.parse(decryptedMessage); - - console.log('๐Ÿ“ฉ Decrypted message type:', message.type); - if (message.type === 'text') { - receivedText = message.data; - console.log('๐Ÿ“ Text content received'); - } else if (message.type === 'file_info') { - currentReceivingFile = { - name: message.name, - size: message.size, - type: message.mimeType, - chunks: [], - receivedSize: 0 - }; - receivingFiles = true; - console.log('๐Ÿ“ File transfer started:', message.name); - } else if (message.type === 'file_chunk' && currentReceivingFile) { - const chunkData = new Uint8Array(message.data); - currentReceivingFile.chunks.push(chunkData); - currentReceivingFile.receivedSize += chunkData.length; - transferProgress = (currentReceivingFile.receivedSize / currentReceivingFile.size) * 100; - - console.log(`๐Ÿ“ฆ File chunk received: ${Math.round(transferProgress)}%`); - - if (currentReceivingFile.receivedSize >= currentReceivingFile.size) { - const completeFile = new Blob(currentReceivingFile.chunks, { type: currentReceivingFile.type }); - receivedFiles = [...receivedFiles, { - name: currentReceivingFile.name, - size: currentReceivingFile.size, - type: currentReceivingFile.type, - blob: completeFile - }]; - - console.log('โœ… File transfer completed:', currentReceivingFile.name); - currentReceivingFile = null; - receivingFiles = false; - transferProgress = 0; - } - } - } catch (error) { - console.error('โŒ Error handling data channel message:', error); - } - }; - - // If the channel is already open, set the state immediately - if (channel.readyState === 'open') { - console.log('โœ… Data channel was already open, setting peerConnected to true'); - peerConnected = true; - } else { - console.log('โณ Data channel not yet open, current state:', channel.readyState); - } - } async function handleOffer(offer: RTCSessionDescriptionInit): Promise { - if (!peerConnection) { - console.error('โŒ No peer connection when handling offer'); - return; - } - - try { - console.log('๐Ÿ“ฅ Received offer, setting as remote description...'); - console.log('Offer SDP preview:', offer.sdp?.substring(0, 200) + '...'); - - await peerConnection.setRemoteDescription(offer); - console.log('โœ… Remote description set successfully'); - - console.log('โณ Creating answer...'); - const answer = await peerConnection.createAnswer(); - await peerConnection.setLocalDescription(answer); - console.log('โœ… Answer created and set as local description'); - console.log('Answer SDP preview:', answer.sdp?.substring(0, 200) + '...'); - - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'answer', answer: answer })); - console.log('๐Ÿ“ค Answer sent via WebSocket'); - } else { - console.error('โŒ WebSocket not ready when sending answer'); - } - } catch (error) { - console.error('โŒ Error handling offer:', error); + function handleJoinSession() { + if (joinCode.trim()) { + clipboardManager?.joinSession(joinCode.trim()); } } - async function handleAnswer(answer: RTCSessionDescriptionInit): Promise { - if (!peerConnection) { - console.error('โŒ No peer connection when handling answer'); - return; - } - - try { - console.log('๐Ÿ“ฅ Received answer, setting as remote description...'); - console.log('Answer SDP preview:', answer.sdp?.substring(0, 200) + '...'); - - await peerConnection.setRemoteDescription(answer); - console.log('โœ… Remote description set successfully from answer'); - } catch (error) { - console.error('โŒ Error handling answer:', error); - } + function handleShareSession() { + clipboardManager?.shareSession(sessionId); } - async function handleIceCandidate(candidate: RTCIceCandidateInit): Promise { - if (!peerConnection) { - console.error('โŒ No peer connection when handling ICE candidate'); - return; - } - - try { - console.log('๐ŸงŠ Received ICE candidate:', { - candidate: candidate.candidate, - sdpMid: candidate.sdpMid, - sdpMLineIndex: candidate.sdpMLineIndex - }); - - // Wait for remote description to be set before adding ICE candidates - if (peerConnection.remoteDescription) { - await peerConnection.addIceCandidate(candidate); - console.log('โœ… ICE candidate added successfully'); - } else { - console.warn('โš ๏ธ Remote description not set yet, queuing ICE candidate'); - // You might want to queue candidates here, but for simplicity we'll just wait - setTimeout(async () => { - if (peerConnection && peerConnection.remoteDescription) { - try { - await peerConnection.addIceCandidate(candidate); - console.log('โœ… Queued ICE candidate added successfully'); - } catch (error) { - console.error('โŒ Error adding queued ICE candidate:', error); - } - } - }, 1000); - } - } catch (error) { - console.error('โŒ Error handling ICE candidate:', error); - console.error('ICE candidate details:', candidate); - } + function handleCleanup() { + clipboardManager?.cleanup(); } - async function sendText(): Promise { - if (!dataChannel || !peerConnected || !textContent.trim()) return; - - try { - const message = { type: 'text', data: textContent.trim() }; - const encryptedData = await encryptData(JSON.stringify(message)); - - if (dataChannel) { - dataChannel.send(encryptedData); - textContent = ''; - } - } catch (error) { - console.error('Error sending text:', error); - } + function handleTabChange(event: CustomEvent<'files' | 'text'>) { + clipboardState.update(state => ({ ...state, activeTab: event.detail })); } - async function sendFiles(): Promise { - if (!dataChannel || !peerConnected || files.length === 0) return; - - sendingFiles = true; - transferProgress = 0; - - try { - for (let i = 0; i < files.length; i++) { - const file = files[i]; - - // Send file info - const fileInfo = { type: 'file_info', name: file.name, size: file.size, mimeType: file.type }; - const encryptedInfo = await encryptData(JSON.stringify(fileInfo)); - dataChannel.send(encryptedInfo); - - // Send file chunks - const totalChunks = Math.ceil(file.size / CHUNK_SIZE); - for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { - const start = chunkIndex * CHUNK_SIZE; - const end = Math.min(start + CHUNK_SIZE, file.size); - const chunk = file.slice(start, end); - const chunkArray = new Uint8Array(await chunk.arrayBuffer()); - - const chunkMessage = { type: 'file_chunk', data: Array.from(chunkArray) }; - const encryptedChunk = await encryptData(JSON.stringify(chunkMessage)); - dataChannel.send(encryptedChunk); - - const fileProgress = (chunkIndex + 1) / totalChunks; - const totalProgress = ((i + fileProgress) / files.length) * 100; - transferProgress = totalProgress; - - // Small delay to prevent overwhelming the connection - await new Promise(resolve => setTimeout(resolve, 10)); - } - } - - files = []; - transferProgress = 0; - sendingFiles = false; - } catch (error) { - console.error('Error sending files:', error); - sendingFiles = false; - } + // File transfer handlers + function handleFilesSelected(event: CustomEvent) { + clipboardState.update(state => ({ + ...state, + files: [...state.files, ...event.detail.files] + })); } - function handleFileSelect(event: Event): void { - const target = event.target as HTMLInputElement; - if (target.files) { - files = [...files, ...Array.from(target.files)]; - } + function handleRemoveFile(event: CustomEvent) { + clipboardState.update(state => ({ + ...state, + files: state.files.filter((_, i) => i !== event.detail.index) + })); + } function handleSendFiles() { + clipboardManager?.sendFiles(); } - function handleDragOver(event: DragEvent): void { - event.preventDefault(); - dragover = true; - } - - function handleDragLeave(): void { - dragover = false; - } - - function handleDrop(event: DragEvent): void { - event.preventDefault(); - dragover = false; - if (event.dataTransfer?.files) { - files = [...files, ...Array.from(event.dataTransfer.files)]; - } - } - - function removeFile(index: number): void { - files = files.filter((_, i) => i !== index); - } - - function downloadReceivedFile(file: FileItem): void { + function handleDownloadFile(event: CustomEvent) { + const file = event.detail.file; const url = URL.createObjectURL(file.blob); const a = document.createElement('a'); a.href = url; @@ -946,108 +128,55 @@ URL.revokeObjectURL(url); } - function removeReceivedFile(index: number): void { - const file = receivedFiles[index]; - URL.revokeObjectURL(URL.createObjectURL(file.blob)); - receivedFiles = receivedFiles.filter((_, i) => i !== index); - } - - function formatFileSize(bytes: number): string { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; - } async function generateQRCode(): Promise { - try { - if (typeof window !== 'undefined' && sessionId) { - // For QR codes, use the actual IP address instead of localhost - let origin = window.location.origin; - if (origin.includes('localhost') || origin.includes('127.0.0.1')) { - // Use the same IP that we use for WebSocket connections - origin = origin.replace(/localhost:\d+|127\.0\.0\.1:\d+/, '192.168.1.12:5173'); - } - - const url = `${origin}/clipboard?session=${sessionId}`; - qrCodeUrl = await QRCode.toDataURL(url, { - width: 200, - margin: 2, - color: { dark: '#000000', light: '#ffffff' } - }); - console.log('QR Code generated:', { sessionId, url, qrCodeUrl: 'data URL created' }); - } - } catch (error) { - console.error('QR Code generation failed:', error); - console.log('QR Code generation failed:', { hasWindow: typeof window !== 'undefined', sessionId }); - } - } function shareSession(): void { - if (typeof window !== 'undefined' && sessionId) { - // Use the same logic as QR code generation for consistency - let origin = window.location.origin; - if (origin.includes('localhost') || origin.includes('127.0.0.1')) { - origin = origin.replace(/localhost:\d+|127\.0\.0\.1:\d+/, '192.168.1.12:5173'); - } - - const url = `${origin}/clipboard?session=${sessionId}`; - navigator.clipboard.writeText(url); - } - } - - async function restartWebRTC(): Promise { - console.log('๐Ÿ”„ Manually restarting WebRTC connection...'); + function handleRemoveReceivedFile(event: CustomEvent) { + clipboardState.update(state => ({ + ...state, + receivedFiles: state.receivedFiles.filter((_, i) => i !== event.detail.index) + })); + } // Text sharing handlers + function handleSendText(event?: CustomEvent) { + const text = event?.detail?.text || textContent; + console.log('handleSendText called with text:', text); + console.log('clipboardManager exists:', !!clipboardManager); + console.log('dataChannel ready:', clipboardManager?.debugInfo?.dataChannel?.readyState); + console.log('peerConnected:', peerConnected); - // Close existing connections - if (dataChannel) { - console.log('๐Ÿงน Closing existing data channel...'); - dataChannel.close(); - dataChannel = null; - } - - if (peerConnection) { - console.log('๐Ÿงน Closing existing peer connection...'); - peerConnection.close(); - peerConnection = null; - } - - // Reset state - peerConnected = false; - - // Wait a bit before restarting - await new Promise(resolve => setTimeout(resolve, 1000)); - - // Restart WebRTC - if (isCreator) { - console.log('๐Ÿ”„ Restarting as creator (creating offer)...'); - await setupWebRTC(true); + if (text.trim()) { + clipboardManager?.sendText(text); } else { - console.log('โš ๏ธ Only creator can initiate restart. Waiting for creator to restart...'); + console.log('No text to send'); } - } function cleanup(): void { - if (dataChannel) { - dataChannel.close(); - dataChannel = null; - } - if (peerConnection) { - peerConnection.close(); - peerConnection = null; - } - if (ws) { - ws.close(); - ws = null; - } - - sessionId = ''; - isConnected = false; - peerConnected = false; - sharedKey = null; - remotePublicKey = null; - qrCodeUrl = ''; - clearStoredSession(); } - function switchTab(tab: 'files' | 'text'): void { - activeTab = tab; + function handleClearText() { + clipboardState.update(state => ({ ...state, receivedText: '' })); } + + // Debug handlers + function handleRestartWebRTC() { + // Implementation would be in clipboard manager + console.log('Restart WebRTC'); + } + + function handleForceConnection() { + // Implementation would be in clipboard manager + console.log('Force connection'); + } // Lifecycle functions + onMount(async () => { + clipboardManager = new ClipboardManager(); + + // Check for session parameter in URL + if (typeof window !== 'undefined') { + const urlParams = new URLSearchParams(window.location.search); + const sessionParam = urlParams.get('session'); + if (sessionParam) { + joinCode = sessionParam; + await clipboardManager.joinSession(joinCode); + } + } + }); onDestroy(() => { + clipboardManager?.cleanup(); + }); @@ -1061,339 +190,70 @@

{$t("clipboard.title")}

{$t("clipboard.description")}

-
+ + - {#if !isConnected} - -
-
-

{$t("clipboard.create_session")}

-

{$t("clipboard.create_description")}

- - {isCreating ? $t("clipboard.creating") : $t("clipboard.create")} - -
- -
- {$t("general.or")} -
- -
-

{$t("clipboard.join_session")}

-

{$t("clipboard.join_description")}

-
- - - {isJoining ? $t("clipboard.joining") : $t("clipboard.join")} - -
-
-
-
- {:else} - - -
-
-
- {$t("clipboard.session_id")}: - {sessionId} - - ๐Ÿ“‹ - -
- - {#if isCreator && sessionId && !peerConnected && qrCodeUrl} -
-

{$t("clipboard.scan_qr")}

- QR Code -
- {/if} - -
- - {peerConnected ? $t("clipboard.peer_connected") : $t("clipboard.waiting_peer")} -
-
-
-
+ {#if isConnected && peerConnected} + + - {#if peerConnected} - -
- - -
- - - {#if activeTab === 'files'} - -
-
-

ๅ‘้€ๆ–‡ไปถ

-
-

ๆ‹–ๆ”พๆ–‡ไปถๅˆฐ่ฟ™้‡Œๆˆ–็‚นๅ‡ป้€‰ๆ‹ฉ

- - fileInput?.click()} - > - ้€‰ๆ‹ฉๆ–‡ไปถ - -
- - {#if files.length > 0} -
-

ๅพ…ๅ‘้€ๆ–‡ไปถ๏ผš

- {#each files as file, index} -
- {file.name} - ({formatFileSize(file.size)}) - -
- {/each} - - {sendingFiles ? 'ๅ‘้€ไธญ...' : 'ๅ‘้€ๆ–‡ไปถ'} - -
- {/if} - - {#if sendingFiles} -
-

ๅ‘้€่ฟ›ๅบฆ๏ผš

-
-
-
- {Math.round(transferProgress)}% -
- {/if} -
- -
-

ๆŽฅๆ”ถๅˆฐ็š„ๆ–‡ไปถ

- {#if receivedFiles.length > 0} -
- {#each receivedFiles as file, index} -
- {file.name} - ({formatFileSize(file.size)}) - downloadReceivedFile(file)} - > - ไธ‹่ฝฝ - - -
- {/each} -
- {:else} -

่ฟ˜ๆฒกๆœ‰ๆŽฅๆ”ถๅˆฐไปปไฝ•ๆ–‡ไปถ

- {/if} - - {#if receivingFiles && currentReceivingFile} -
-

ๆŽฅๆ”ถๆ–‡ไปถ๏ผš{currentReceivingFile.name}

-
-
-
- {Math.round(transferProgress)}% ({formatFileSize(currentReceivingFile.receivedSize)} / {formatFileSize(currentReceivingFile.size)}) -
- {/if} -
-
-
- {/if} - - - {#if activeTab === 'text'} - -
-
-

ๅ‘้€ๆ–‡ๆœฌ

- - - ๅ‘้€ๆ–‡ๆœฌ - -
-

ๆŽฅๆ”ถๅˆฐ็š„ๆ–‡ๆœฌ

- {#if receivedText} -
-
{receivedText}
- navigator.clipboard.writeText(receivedText)} - > - ๅคๅˆถๆ–‡ๆœฌ - -
- {:else} -

่ฟ˜ๆฒกๆœ‰ๆŽฅๆ”ถๅˆฐไปปไฝ•ๆ–‡ๆœฌ

- {/if} -
-
- {/if} + {#if activeTab === 'files'} + {/if} - - -
-
- ๐Ÿ”ง Connection Debug -
-

WebSocket: {isConnected ? 'โœ… Connected' : 'โŒ Disconnected'}

-

Session ID: {sessionId || 'Not set'}

-

Is Creator: {isCreator ? 'Yes' : 'No'}

-

Peer Connected: {peerConnected ? 'โœ… Yes' : 'โŒ No'}

-

Data Channel: {dataChannel ? (dataChannel.readyState || 'Unknown') : 'Not created'}

Peer Connection: {peerConnection ? (peerConnection.connectionState || 'Unknown') : 'Not created'}

-

Signaling State: {peerConnection ? (peerConnection.signalingState || 'Unknown') : 'Not created'}

-

ICE Connection: {peerConnection ? (peerConnection.iceConnectionState || 'Unknown') : 'Not created'}

-

ICE Gathering: {peerConnection ? (peerConnection.iceGatheringState || 'Unknown') : 'Not created'}

- -
- - - {#if dataChannel && dataChannel.readyState === 'open' && !peerConnected} - - {/if} - {#if peerConnection && dataChannel && dataChannel.readyState === 'connecting'} - - {/if} - - -
- - {#if dataChannel && dataChannel.readyState === 'connecting'} -
- โš ๏ธ Data channel stuck in "connecting" state. This usually indicates: -
    -
  • ICE connection issues
  • -
  • Firewall blocking WebRTC
  • -
  • NAT traversal problems
  • -
- Try the "Restart WebRTC" button above. -
{/if} -
-
-
-
+ {#if activeTab === 'text'} + + {/if} + {/if} {#if isConnected} +
- + {$t("clipboard.disconnect")}
@@ -1401,199 +261,6 @@ diff --git a/web/src/routes/clipboard/+page.svelte.backup b/web/src/routes/clipboard/+page.svelte.backup deleted file mode 100644 index fbc73a4e..00000000 --- a/web/src/routes/clipboard/+page.svelte.backup +++ /dev/null @@ -1,1112 +0,0 @@ - - - - cobalt | {$t("clipboard.title")} - - - - - -
-
- -

{$t("clipboard.title")}

-

{$t("clipboard.description")}

-
- - {#if !isConnected} - -
-
-

{$t("clipboard.create_session")}

-

{$t("clipboard.create_description")}

- - {isCreating ? $t("clipboard.creating") : $t("clipboard.create")} - -
- -
- {$t("general.or")} -
- -
-

{$t("clipboard.join_session")}

-

{$t("clipboard.join_description")}

-
- - - {isJoining ? $t("clipboard.joining") : $t("clipboard.join")} - -
-
-
-
- {:else} - -
-

{$t("clipboard.session_active")}

-
-
- {$t("clipboard.session_id")}: - {sessionId} - - ๐Ÿ“‹ - -
{#if isCreator && sessionId && !peerConnected && qrCodeUrl} -
-

{$t("clipboard.scan_qr")}

- QR Code -
- {/if} - -
- - {peerConnected ? $t("clipboard.peer_connected") : $t("clipboard.waiting_peer")} -
-
-
-
- - {#if peerConnected} - -
-

{$t("clipboard.send_text")}

- - - {$t("clipboard.send")} - -
-
- - -
-

{$t("clipboard.send_files")}

- -
dragover = true} - on:dragleave={() => dragover = false} - on:drop={handleDrop} - on:click={() => document.getElementById('file-input')?.click()} - on:keydown={(e) => e.key === 'Enter' && document.getElementById('file-input')?.click()} - > -
-

{$t("clipboard.drop_files")}

- - document.getElementById('file-input')?.click()} - > - {$t("clipboard.select_files")} - -
-
- - {#if files.length > 0} -
- {#each files as file, index} -
-
- {file.name} - {formatFileSize(file.size)} -
- removeFile(index)} - > - โœ• - -
- {/each} -
- -
- - {sendingFiles ? $t("clipboard.sending") : $t("clipboard.send")} - - - {#if sendingFiles || transferProgress > 0} -
-
-
- {Math.round(transferProgress)}% - {/if} -
- {/if} -
-
- - {#if receivedFiles.length > 0} - -
-

{$t("clipboard.received_files")}

-
- {#each receivedFiles as file, index} -
-
- {file.name} - {formatFileSize(file.size)} -
-
- downloadReceivedFile(file)} - > - โฌ‡ {$t("clipboard.download")} - - removeReceivedFile(index)} - > - โœ• - -
-
- {/each} -
-
-
- {/if} - - {#if receivingFiles && currentReceivingFile} - -
-

{$t("clipboard.receiving")}: {currentReceivingFile.name}

-
-
-
- {Math.round(transferProgress)}% ({formatFileSize(currentReceivingFile.receivedSize)} / {formatFileSize(currentReceivingFile.size)}) -
-
- {/if} - {/if} - - -
- - {$t("clipboard.disconnect")} - -
-
- {/if} -
- -