文件传输5

This commit is contained in:
celebrateyang 2025-06-04 22:54:49 +08:00
parent 1c15767976
commit 97381db69b
10 changed files with 3547 additions and 2826 deletions

View File

@ -0,0 +1,160 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import SettingsCategory from '$components/settings/SettingsCategory.svelte';
const dispatch = createEventDispatcher();
export let isConnected: boolean;
export let sessionId: string;
export let isCreator: boolean;
export let peerConnected: boolean;
export let dataChannel: RTCDataChannel | null;
export let peerConnection: RTCPeerConnection | null;
function restartWebRTC(): void {
dispatch('restartWebRTC');
}
function forceConnection(): void {
dispatch('forceConnection');
}
</script>
<SettingsCategory title="Debug Panel" sectionId="debug-panel">
<div class="debug-panel">
<details>
<summary>🔧 Connection Debug</summary>
<div class="debug-info">
<p><strong>WebSocket:</strong> {isConnected ? '✅ Connected' : '❌ Disconnected'}</p>
<p><strong>Session ID:</strong> {sessionId || 'Not set'}</p>
<p><strong>Is Creator:</strong> {isCreator ? 'Yes' : 'No'}</p>
<p><strong>Peer Connected:</strong> {peerConnected ? '✅ Yes' : '❌ No'}</p>
<p><strong>Data Channel:</strong> {dataChannel ? (dataChannel.readyState || 'Unknown') : 'Not created'}</p>
<p><strong>Peer Connection:</strong> {peerConnection ? (peerConnection.connectionState || 'Unknown') : 'Not created'}</p>
<p><strong>Signaling State:</strong> {peerConnection ? (peerConnection.signalingState || 'Unknown') : 'Not created'}</p>
<p><strong>ICE Connection:</strong> {peerConnection ? (peerConnection.iceConnectionState || 'Unknown') : 'Not created'}</p>
<p><strong>ICE Gathering:</strong> {peerConnection ? (peerConnection.iceGatheringState || 'Unknown') : 'Not created'}</p>
<div class="debug-actions">
<button
class="debug-btn restart-btn"
on:click={restartWebRTC}
disabled={!isCreator}
>
🔄 Restart WebRTC
</button>
{#if dataChannel && dataChannel.readyState === 'open' && !peerConnected}
<button
class="debug-btn"
on:click={forceConnection}
>
🔗 Force Connection
</button>
{/if}
</div>
{#if dataChannel && dataChannel.readyState === 'connecting'}
<div class="debug-warning">
<strong>⚠️ Data channel stuck in 'connecting' state</strong>
<ul>
<li>This usually indicates network connectivity issues</li>
<li>Check firewall settings and network restrictions</li>
<li>Try restarting the WebRTC connection</li>
</ul>
</div>
{/if}
</div>
</details>
</div>
</SettingsCategory>
<style>
.debug-panel {
margin-top: 1rem;
border: 1px solid var(--border);
border-radius: 0.25rem;
overflow: hidden;
}
.debug-panel summary {
padding: 0.5rem;
background-color: var(--background-alt);
cursor: pointer;
font-size: 0.85rem;
border-bottom: 1px solid var(--border);
}
.debug-panel summary:hover {
background-color: var(--hover-background);
}
.debug-info {
padding: 0.75rem;
font-size: 0.8rem;
line-height: 1.4;
}
.debug-info p {
margin: 0.25rem 0;
font-family: monospace;
}
.debug-actions {
margin-top: 0.5rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.debug-btn {
padding: 0.25rem 0.5rem;
background-color: var(--accent-background);
border: 1px solid var(--accent);
border-radius: 0.25rem;
color: var(--accent);
cursor: pointer;
font-size: 0.75rem;
transition: all 0.2s ease;
}
.debug-btn:hover:not(:disabled) {
background-color: var(--accent);
color: var(--background);
}
.debug-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.restart-btn {
background-color: var(--orange-background);
border-color: var(--orange);
color: var(--orange);
}
.restart-btn:hover:not(:disabled) {
background-color: var(--orange);
color: var(--background);
}
.debug-warning {
margin-top: 0.75rem;
padding: 0.5rem;
background-color: var(--yellow-background);
border: 1px solid var(--yellow);
border-radius: 0.25rem;
font-size: 0.75rem;
color: var(--text);
}
.debug-warning ul {
margin: 0.25rem 0 0 1rem;
padding: 0;
}
.debug-warning li {
margin: 0.1rem 0;
}
</style>

View File

@ -0,0 +1,334 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import SettingsCategory from '$components/settings/SettingsCategory.svelte';
import ActionButton from '$components/buttons/ActionButton.svelte';
const dispatch = createEventDispatcher();
export let files: File[];
export let receivedFiles: any[];
export let sendingFiles: boolean;
export let receivingFiles: boolean;
export let transferProgress: number;
export let dragover: boolean;
export let peerConnected: boolean;
let fileInput: HTMLInputElement;
function handleFileSelect(event: Event): void {
const target = event.target as HTMLInputElement;
if (target.files) {
const newFiles = Array.from(target.files);
dispatch('filesSelected', { files: newFiles });
}
}
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) {
const droppedFiles = Array.from(event.dataTransfer.files);
dispatch('filesSelected', { files: droppedFiles });
}
}
function removeFile(index: number): void {
dispatch('removeFile', { index });
}
function sendFiles(): void {
dispatch('sendFiles');
}
function downloadReceivedFile(file: any): void {
dispatch('downloadFile', { file });
}
function removeReceivedFile(index: number): void {
dispatch('removeReceivedFile', { 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];
}
</script>
<SettingsCategory title="文件传输" sectionId="file-transfer">
<div class="file-transfer-section">
<div class="send-files">
<h4>发送文件</h4>
<div
class="file-drop-zone"
class:dragover
on:dragover={handleDragOver}
on:dragleave={handleDragLeave}
on:drop={handleDrop}
role="button"
tabindex="0"
on:click={() => fileInput?.click()}
on:keydown={(e) => e.key === 'Enter' && fileInput?.click()}
>
<p>📁 拖拽文件到这里或点击选择</p>
<input
bind:this={fileInput}
type="file"
multiple
on:change={handleFileSelect}
style="display: none;"
/>
</div>
{#if files.length > 0}
<div class="file-list">
<h5>待发送文件:</h5>
{#each files as file, index (file.name + index)}
<div class="file-item">
<span class="file-name">{file.name}</span>
<span class="file-size">({formatFileSize(file.size)})</span>
<button
class="remove-file"
on:click={() => removeFile(index)}
aria-label="Remove file"
>
</button>
</div>
{/each}
<ActionButton
id="send-files"
disabled={!peerConnected || sendingFiles}
click={sendFiles}
>
{sendingFiles ? '发送中...' : '发送文件'}
</ActionButton>
</div>
{/if}
{#if sendingFiles}
<div class="progress-section">
<h4>发送进度: {Math.round(transferProgress)}%</h4>
<div class="progress-bar">
<div class="progress-fill" style="width: {transferProgress}%"></div>
</div>
</div>
{/if}
</div>
<div class="received-files">
<h4>已接收文件</h4>
{#if receivingFiles}
<div class="progress-section">
<h4>接收进度: {Math.round(transferProgress)}%</h4>
<div class="progress-bar">
<div class="progress-fill" style="width: {transferProgress}%"></div>
</div>
</div>
{/if}
{#if receivedFiles.length > 0}
<div class="file-list">
{#each receivedFiles as file, index (file.name + index)}
<div class="file-item">
<span class="file-name">{file.name}</span>
<span class="file-size">({formatFileSize(file.size)})</span>
<div class="file-actions">
<button
class="download-btn"
on:click={() => downloadReceivedFile(file)}
>
📥 下载
</button>
<button
class="remove-file"
on:click={() => removeReceivedFile(index)}
aria-label="Remove file"
>
</button>
</div>
</div>
{/each}
</div>
{:else if !receivingFiles}
<div class="empty-state">
暂无接收到的文件
</div>
{/if}
</div>
</div>
</SettingsCategory>
<style>
.file-transfer-section {
display: flex;
flex-direction: column;
gap: 2rem;
}
.send-files, .received-files {
display: flex;
flex-direction: column;
gap: 1rem;
}
.file-drop-zone {
border: 2px dashed var(--border);
border-radius: 0.5rem;
padding: 2rem;
text-align: center;
transition: all 0.2s ease;
background-color: var(--background);
cursor: pointer;
}
.file-drop-zone:hover {
border-color: var(--accent);
background-color: var(--accent-background);
}
.file-drop-zone.dragover {
border-color: var(--accent);
background-color: var(--accent-background);
transform: scale(1.02);
}
.file-drop-zone p {
margin-bottom: 1rem;
color: var(--secondary);
}
.file-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.file-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background-color: var(--background-alt);
border-radius: 0.25rem;
border: 1px solid var(--border);
}
.file-name {
flex: 1;
font-weight: 500;
color: var(--text);
}
.file-size {
color: var(--secondary);
font-size: 0.9rem;
}
.file-actions {
display: flex;
gap: 0.5rem;
}
.download-btn {
background-color: var(--accent-background);
border: 1px solid var(--accent);
color: var(--accent);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.8rem;
transition: all 0.2s ease;
}
.download-btn:hover {
background-color: var(--accent);
color: var(--background);
}
.remove-file {
background: none;
border: none;
color: var(--red);
cursor: pointer;
padding: 0.25rem;
border-radius: 0.25rem;
transition: background-color 0.2s;
}
.remove-file:hover {
background-color: var(--red-background);
}
.progress-section {
margin-top: 1rem;
padding: 1rem;
background-color: var(--background-alt);
border-radius: 0.5rem;
border: 1px solid var(--border);
}
.progress-section h4 {
margin: 0 0 0.5rem 0;
color: var(--text);
font-size: 0.9rem;
}
.progress-bar {
width: 100%;
height: 8px;
background-color: var(--border);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background-color: var(--accent);
transition: width 0.3s ease;
}
.empty-state {
text-align: center;
color: var(--secondary);
font-style: italic;
padding: 2rem;
border: 1px dashed var(--border);
border-radius: 0.5rem;
background-color: var(--background-alt);
}
@media (max-width: 768px) {
.file-transfer-section {
gap: 1.5rem;
}
.file-drop-zone {
padding: 1.5rem 1rem;
}
.file-item {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.file-actions {
align-self: flex-end;
}
}
</style>

View File

@ -0,0 +1,265 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { t } from '$lib/i18n/translations';
import SettingsCategory from '$components/settings/SettingsCategory.svelte';
import ActionButton from '$components/buttons/ActionButton.svelte';
const dispatch = createEventDispatcher();
export let isConnected: boolean;
export let isCreating: boolean;
export let isJoining: boolean;
export let joinCode: string;
export let sessionId: string;
export let isCreator: boolean;
export let peerConnected: boolean;
export let qrCodeUrl: string;
function handleCreateSession() {
dispatch('createSession');
}
function handleJoinSession() {
dispatch('joinSession');
}
function handleShare() {
dispatch('shareSession');
}
function handleCleanup() {
dispatch('cleanup');
}
</script>
{#if !isConnected}
<SettingsCategory title={$t("clipboard.title")} sectionId="connection-setup">
<div class="connection-setup">
<div class="setup-option">
<h3>{$t("clipboard.create_session")}</h3>
<p>{$t("clipboard.create_description")}</p> <ActionButton
id="create-session"
disabled={isCreating}
click={handleCreateSession}
>
{isCreating ? 'Creating...' : $t("clipboard.create_session")}
</ActionButton>
</div>
<div class="divider">
<span>{$t("general.or")}</span>
</div>
<div class="setup-option">
<h3>{$t("clipboard.join_session")}</h3>
<p>{$t("clipboard.join_description")}</p>
<div class="join-form">
<input
type="text"
bind:value={joinCode}
placeholder="Enter session code"
disabled={isJoining}
/> <ActionButton
id="join-session"
disabled={isJoining || !joinCode.trim()}
click={handleJoinSession}
>
{isJoining ? 'Joining...' : $t("clipboard.join_session")}
</ActionButton>
</div>
</div>
</div>
</SettingsCategory>
{:else}
<!-- Session Info -->
<SettingsCategory title={$t("clipboard.session_active")} sectionId="session-info">
<div class="session-info">
<div class="session-details">
<div class="session-id">
<span>Session ID:</span>
<code>{sessionId}</code>
<button class="copy-btn" on:click={handleShare}>📋</button>
</div>
{#if isCreator && sessionId && !peerConnected && qrCodeUrl}
<div class="qr-code">
<h4>Scan to join:</h4>
<img src={qrCodeUrl} alt="QR Code" />
</div>
{/if}
<div class="connection-status">
<div class="status-indicator" class:connected={peerConnected}></div>
<span>{peerConnected ? 'Connected' : 'Waiting for peer...'}</span>
</div>
</div>
</div>
</SettingsCategory>
<!-- Disconnect Section -->
<div class="disconnect-section">
<ActionButton id="cleanup" click={handleCleanup}>
{$t("clipboard.disconnect")}
</ActionButton>
</div>
{/if}
<style>
.connection-setup {
display: flex;
flex-direction: column;
gap: 2rem;
}
.setup-option {
text-align: center;
}
.setup-option h3 {
margin-bottom: 0.5rem;
}
.divider {
text-align: center;
position: relative;
margin: 1rem 0;
}
.divider::before {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background-color: var(--border);
}
.divider span {
background-color: var(--background);
padding: 0 1rem;
color: var(--secondary);
}
.join-form {
display: flex;
gap: 1rem;
max-width: 400px;
margin: 0 auto;
}
.join-form input {
flex: 1;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 0.25rem;
background-color: var(--input-background);
color: var(--text);
}
.session-info {
text-align: center;
}
.session-details {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
}
.session-id {
display: flex;
align-items: center;
gap: 0.5rem;
}
.session-id code {
background-color: var(--border);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-family: monospace;
}
.copy-btn {
background: none;
border: none;
cursor: pointer;
padding: 0.25rem;
border-radius: 0.25rem;
}
.copy-btn:hover {
background-color: var(--hover-background);
}
.qr-code {
text-align: center;
}
.qr-code img {
max-width: 200px;
border: 1px solid var(--border);
border-radius: 0.5rem;
}
.connection-status {
display: flex;
align-items: center;
gap: 0.5rem;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
background-color: var(--red);
}
.status-indicator.connected {
background-color: var(--green);
}
.disconnect-section {
text-align: center;
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid var(--border);
}
@media (min-width: 768px) {
.connection-setup {
flex-direction: row;
align-items: flex-start;
}
.setup-option {
flex: 1;
}
.divider {
align-self: center;
margin: 0 2rem;
width: 60px;
}
.divider::before {
top: 0;
bottom: 0;
left: 50%;
right: auto;
width: 1px;
height: auto;
}
.session-details {
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
}
.qr-code {
order: -1;
}
}
</style>

View File

@ -0,0 +1,84 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let activeTab: 'files' | 'text';
const dispatch = createEventDispatcher<{
tabChange: 'files' | 'text'
}>();
</script>
<div class="tab-navigation">
<button
class="tab-button"
class:active={activeTab === 'files'}
on:click={() => dispatch('tabChange', 'files')}
>
📁 文件传输
</button>
<button
class="tab-button"
class:active={activeTab === 'text'}
on:click={() => dispatch('tabChange', 'text')}
>
📝 文本分享
</button>
</div>
<style>
.tab-navigation {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
padding: 0.25rem;
background-color: var(--button);
border-radius: 0.5rem;
max-width: fit-content;
margin: 0 auto 1.5rem auto;
}
.tab-button {
padding: 0.75rem 1.5rem;
border: none;
background-color: transparent;
color: var(--secondary);
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.5rem;
}
.tab-button:hover {
background-color: var(--button-hover);
color: var(--text);
}
.tab-button.active {
background-color: var(--secondary);
color: var(--primary);
cursor: default;
}
.tab-button.active:hover {
background-color: var(--secondary);
color: var(--primary);
}
@media (max-width: 768px) {
.tab-navigation {
width: 100%;
max-width: none;
}
.tab-button {
flex: 1;
justify-content: center;
padding: 0.75rem 1rem;
font-size: 0.85rem;
}
}
</style>

View File

@ -0,0 +1,189 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import SettingsCategory from '$components/settings/SettingsCategory.svelte';
import ActionButton from '$components/buttons/ActionButton.svelte';
const dispatch = createEventDispatcher();
export let textContent: string;
export let receivedText: string;
export let peerConnected: boolean;
function sendText(): void {
if (textContent.trim()) {
dispatch('sendText', { text: textContent });
}
}
function clearReceivedText(): void {
dispatch('clearText');
}
function copyReceivedText(): void {
if (receivedText && navigator.clipboard) {
navigator.clipboard.writeText(receivedText);
}
}
</script>
<SettingsCategory title="文本分享" sectionId="text-sharing">
<div class="text-sharing-section">
<div class="send-text">
<h4>发送文本</h4>
<textarea
class="text-input"
bind:value={textContent}
placeholder="输入要发送的文本..."
rows="4"
disabled={!peerConnected}
></textarea> <ActionButton
id="send-text"
disabled={!peerConnected || !textContent.trim()}
click={sendText}
>
发送文本
</ActionButton>
</div>
<div class="received-text">
<h4>已接收文本</h4>
{#if receivedText}
<div class="text-display">
<div class="text-content">{receivedText}</div>
<div class="text-actions">
<button
class="copy-btn"
on:click={copyReceivedText}
>
📋 复制
</button>
<button
class="clear-btn"
on:click={clearReceivedText}
>
🗑️ 清除
</button>
</div>
</div>
{:else}
<div class="empty-state">
暂无接收到的文本
</div>
{/if}
</div>
</div>
</SettingsCategory>
<style>
.text-sharing-section {
display: flex;
flex-direction: column;
gap: 2rem;
}
.send-text, .received-text {
display: flex;
flex-direction: column;
gap: 1rem;
}
.text-input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
background-color: var(--input-background);
color: var(--text);
resize: vertical;
font-family: inherit;
font-size: 0.9rem;
line-height: 1.5;
min-height: 100px;
}
.text-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-background);
}
.text-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.text-display {
border: 1px solid var(--border);
border-radius: 0.5rem;
background-color: var(--background-alt);
overflow: hidden;
}
.text-content {
white-space: pre-wrap;
word-wrap: break-word;
padding: 1rem;
margin: 0;
font-family: inherit;
font-size: 0.9rem;
line-height: 1.5;
color: var(--text);
max-height: 200px;
overflow-y: auto;
}
.text-actions {
display: flex;
gap: 0.5rem;
padding: 0.75rem;
background-color: var(--background);
border-top: 1px solid var(--border);
}
.copy-btn, .clear-btn {
padding: 0.5rem 1rem;
border: 1px solid var(--border);
border-radius: 0.25rem;
background-color: var(--button);
color: var(--text);
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s ease;
}
.copy-btn:hover {
background-color: var(--accent-background);
border-color: var(--accent);
color: var(--accent);
}
.clear-btn:hover {
background-color: var(--red-background);
border-color: var(--red);
color: var(--red);
}
.empty-state {
text-align: center;
color: var(--secondary);
font-style: italic;
padding: 2rem;
border: 1px dashed var(--border);
border-radius: 0.5rem;
background-color: var(--background-alt);
}
@media (max-width: 768px) {
.text-sharing-section {
gap: 1.5rem;
}
.text-actions {
flex-direction: column;
}
.copy-btn, .clear-btn {
width: 100%;
}
}
</style>

View File

@ -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<typeof setInterval> | 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<void> {
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<void> {
this.keyPair = await window.crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' },
false,
['deriveKey']
);
}
private async exportPublicKey(): Promise<ArrayBuffer> {
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<void> {
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<void> {
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<ArrayBuffer> {
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<ArrayBuffer> {
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<string> {
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<Uint8Array> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
try {
if (this.peerConnection) {
await this.peerConnection.setRemoteDescription(answer);
}
} catch (error) {
console.error('Error handling answer:', error);
}
}
private async handleIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
try {
if (this.peerConnection) {
await this.peerConnection.addIceCandidate(candidate);
}
} catch (error) {
console.error('Error handling ICE candidate:', error);
}
}
private async handleDataChannelMessage(data: any): Promise<void> {
// 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<void> {
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<void> {
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<void> {
// 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'
};
}
}

View File

@ -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<typeof setInterval> | 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<void> {
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<void> {
this.keyPair = await window.crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' },
false,
['deriveKey']
);
}
private async exportPublicKey(): Promise<ArrayBuffer> {
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<void> {
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<void> {
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<ArrayBuffer> {
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<ArrayBuffer> {
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<string> {
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<Uint8Array> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
try {
if (this.peerConnection) {
await this.peerConnection.setRemoteDescription(answer);
}
} catch (error) {
console.error('Error handling answer:', error);
}
}
private async handleIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
try {
if (this.peerConnection) {
await this.peerConnection.addIceCandidate(candidate);
}
} catch (error) {
console.error('Error handling ICE candidate:', error);
}
} private async handleDataChannelMessage(data: any): Promise<void> {
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<void> {
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<void> {
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<void> {
// 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'
};
}
}

View File

@ -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<typeof setInterval> | 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<void> {
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<void> {
this.keyPair = await window.crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' },
false,
['deriveKey']
);
}
private async exportPublicKey(): Promise<ArrayBuffer> {
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<void> {
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<void> {
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<ArrayBuffer> {
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<ArrayBuffer> {
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<string> {
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<Uint8Array> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
try {
if (this.peerConnection) {
await this.peerConnection.setRemoteDescription(answer);
}
} catch (error) {
console.error('Error handling answer:', error);
}
}
private async handleIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
try {
if (this.peerConnection) {
await this.peerConnection.addIceCandidate(candidate);
}
} catch (error) {
console.error('Error handling ICE candidate:', error);
}
} private async handleDataChannelMessage(data: any): Promise<void> {
// 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<void> {
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<void> {
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<void> {
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
};
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff