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