mirror of
https://github.com/imputnet/cobalt.git
synced 2025-06-28 09:28:29 +00:00
文件传输5
This commit is contained in:
parent
1c15767976
commit
97381db69b
160
web/src/components/clipboard/DebugPanel.svelte
Normal file
160
web/src/components/clipboard/DebugPanel.svelte
Normal 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>
|
334
web/src/components/clipboard/FileTransfer.svelte
Normal file
334
web/src/components/clipboard/FileTransfer.svelte
Normal 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>
|
265
web/src/components/clipboard/SessionManager.svelte
Normal file
265
web/src/components/clipboard/SessionManager.svelte
Normal 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>
|
84
web/src/components/clipboard/TabNavigation.svelte
Normal file
84
web/src/components/clipboard/TabNavigation.svelte
Normal 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>
|
189
web/src/components/clipboard/TextSharing.svelte
Normal file
189
web/src/components/clipboard/TextSharing.svelte
Normal 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>
|
776
web/src/lib/clipboard/clipboard-manager-new.ts
Normal file
776
web/src/lib/clipboard/clipboard-manager-new.ts
Normal 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'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
776
web/src/lib/clipboard/clipboard-manager.ts
Normal file
776
web/src/lib/clipboard/clipboard-manager.ts
Normal 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'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
793
web/src/lib/clipboard/clipboard-manager.ts.broken
Normal file
793
web/src/lib/clipboard/clipboard-manager.ts.broken
Normal 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
Loading…
Reference in New Issue
Block a user