@balaji003/lantransfer 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/package.json +34 -0
  2. package/public/index.html +454 -0
  3. package/server.js +506 -0
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@balaji003/lantransfer",
3
+ "version": "1.0.0",
4
+ "description": "LAN File Transfer — peer-to-peer file sharing over local network. Zero dependencies.",
5
+ "main": "server.js",
6
+ "bin": {
7
+ "lantransfer": "server.js"
8
+ },
9
+ "files": [
10
+ "server.js",
11
+ "public/"
12
+ ],
13
+ "scripts": {
14
+ "start": "node server.js"
15
+ },
16
+ "keywords": [
17
+ "lan",
18
+ "file-transfer",
19
+ "local",
20
+ "share",
21
+ "wifi",
22
+ "peer-to-peer",
23
+ "cli"
24
+ ],
25
+ "author": "Balaji Sankaran",
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/user/lantransfer"
30
+ },
31
+ "engines": {
32
+ "node": ">=16.0.0"
33
+ }
34
+ }
@@ -0,0 +1,454 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>LAN File Transfer</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+
10
+ :root {
11
+ --bg: #0f0f1a;
12
+ --surface: #1a1a2e;
13
+ --surface-hover: #222240;
14
+ --surface-active: #2a2a50;
15
+ --accent: #4a9eff;
16
+ --accent-dim: #3a7ecc;
17
+ --green: #4ade80;
18
+ --green-dim: #22c55e;
19
+ --red: #f87171;
20
+ --yellow: #fbbf24;
21
+ --text: #e8e8f0;
22
+ --text-dim: #6b6b8d;
23
+ --border: #2a2a45;
24
+ --radius: 8px;
25
+ }
26
+
27
+ body {
28
+ background: var(--bg);
29
+ color: var(--text);
30
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
31
+ font-size: 14px;
32
+ height: 100vh;
33
+ display: flex;
34
+ flex-direction: column;
35
+ overflow: hidden;
36
+ }
37
+
38
+ /* ── Header ── */
39
+ .header {
40
+ display: flex;
41
+ align-items: center;
42
+ justify-content: space-between;
43
+ padding: 14px 20px;
44
+ background: var(--surface);
45
+ border-bottom: 1px solid var(--border);
46
+ flex-shrink: 0;
47
+ }
48
+ .header-left { display: flex; align-items: center; gap: 16px; }
49
+ .logo { font-size: 20px; font-weight: 700; color: #8cc8ff; }
50
+ .device-info { color: var(--text-dim); font-size: 13px; }
51
+
52
+ .toggle {
53
+ display: flex; align-items: center; gap: 8px;
54
+ cursor: pointer; padding: 6px 16px; border-radius: 20px;
55
+ background: rgba(248, 113, 113, 0.12); color: var(--red);
56
+ font-size: 13px; font-weight: 500; transition: all 0.2s;
57
+ user-select: none; border: 1px solid transparent;
58
+ }
59
+ .toggle:hover { border-color: var(--red); }
60
+ .toggle.active { background: rgba(74, 222, 128, 0.12); color: var(--green); }
61
+ .toggle.active:hover { border-color: var(--green); }
62
+ .toggle-dot {
63
+ width: 8px; height: 8px; border-radius: 50%;
64
+ background: var(--red); transition: background 0.2s;
65
+ }
66
+ .toggle.active .toggle-dot { background: var(--green); }
67
+
68
+ /* ── Main layout ── */
69
+ .main { flex: 1; display: flex; overflow: hidden; }
70
+
71
+ /* ── Sidebar ── */
72
+ .sidebar {
73
+ width: 260px; border-right: 1px solid var(--border);
74
+ display: flex; flex-direction: column; background: var(--surface);
75
+ flex-shrink: 0;
76
+ }
77
+ .section-title {
78
+ padding: 16px 16px 10px; font-size: 12px; font-weight: 600;
79
+ color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.6px;
80
+ }
81
+ .peers { flex: 1; overflow-y: auto; padding: 0 8px 8px; }
82
+
83
+ .peer {
84
+ padding: 10px 12px; border-radius: var(--radius);
85
+ cursor: pointer; transition: background 0.12s; margin-bottom: 2px;
86
+ border: 1px solid transparent;
87
+ }
88
+ .peer:hover { background: var(--surface-hover); }
89
+ .peer.selected {
90
+ background: var(--surface-active);
91
+ border-color: var(--accent);
92
+ }
93
+ .peer-name { font-weight: 500; font-size: 14px; }
94
+ .peer-ip { font-size: 12px; color: var(--text-dim); margin-top: 2px; }
95
+
96
+ .empty-msg {
97
+ padding: 24px 16px; color: var(--text-dim);
98
+ font-size: 13px; text-align: center; line-height: 1.5;
99
+ }
100
+
101
+ /* ── Content ── */
102
+ .content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
103
+
104
+ /* ── Send panel ── */
105
+ .send-panel { padding: 24px; border-bottom: 1px solid var(--border); }
106
+ .send-title { font-size: 16px; font-weight: 600; margin-bottom: 16px; }
107
+ .send-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
108
+
109
+ .btn {
110
+ padding: 9px 20px; border-radius: var(--radius); border: none;
111
+ cursor: pointer; font-size: 14px; font-weight: 500;
112
+ transition: all 0.12s; display: inline-flex; align-items: center; gap: 6px;
113
+ }
114
+ .btn:active { transform: scale(0.97); }
115
+ .btn-browse { background: var(--accent); color: #fff; }
116
+ .btn-browse:hover { background: var(--accent-dim); }
117
+ .btn-send { background: var(--green); color: #0f0f1a; min-width: 140px; justify-content: center; }
118
+ .btn-send:hover { background: var(--green-dim); }
119
+ .btn-send:disabled {
120
+ background: #2a2a3a; color: #555; cursor: not-allowed;
121
+ transform: none;
122
+ }
123
+
124
+ .file-info { color: var(--text-dim); font-size: 13px; }
125
+ .file-info.has-file { color: var(--text); font-weight: 500; }
126
+
127
+ /* ── Transfers ── */
128
+ .transfers-section { flex: 1; overflow-y: auto; padding: 16px 24px; }
129
+ .transfer {
130
+ padding: 14px; background: var(--surface); border-radius: var(--radius);
131
+ margin-bottom: 8px;
132
+ }
133
+ .transfer-top { display: flex; justify-content: space-between; align-items: center; }
134
+ .transfer-info { display: flex; align-items: center; gap: 8px; font-size: 13px; }
135
+ .transfer-icon { font-size: 16px; }
136
+
137
+ .badge {
138
+ font-size: 12px; font-weight: 600; padding: 2px 10px;
139
+ border-radius: 10px; white-space: nowrap;
140
+ }
141
+ .badge-completed { background: rgba(74,222,128,0.15); color: var(--green); }
142
+ .badge-failed { background: rgba(248,113,113,0.15); color: var(--red); }
143
+ .badge-waiting { background: rgba(251,191,36,0.15); color: var(--yellow); }
144
+ .badge-progress { background: rgba(74,158,255,0.15); color: var(--accent); }
145
+
146
+ .progress-track {
147
+ height: 4px; background: var(--border); border-radius: 2px;
148
+ margin-top: 10px; overflow: hidden;
149
+ }
150
+ .progress-fill {
151
+ height: 100%; background: var(--accent); border-radius: 2px;
152
+ transition: width 0.3s ease;
153
+ }
154
+ .transfer-meta {
155
+ display: flex; justify-content: space-between;
156
+ margin-top: 6px; font-size: 12px; color: var(--text-dim);
157
+ }
158
+
159
+ /* ── Modal ── */
160
+ .modal-overlay {
161
+ display: none; position: fixed; inset: 0;
162
+ background: rgba(0,0,0,0.65); backdrop-filter: blur(4px);
163
+ z-index: 100; align-items: center; justify-content: center;
164
+ }
165
+ .modal-overlay.show { display: flex; }
166
+ .modal {
167
+ background: var(--surface); border: 1px solid var(--border);
168
+ border-radius: 14px; padding: 28px 32px; min-width: 380px;
169
+ text-align: center; box-shadow: 0 20px 60px rgba(0,0,0,0.5);
170
+ }
171
+ .modal-title { font-size: 20px; font-weight: 700; margin-bottom: 16px; }
172
+ .modal-sender { color: var(--text-dim); font-size: 14px; margin-bottom: 6px; }
173
+ .modal-file { font-weight: 600; font-size: 16px; margin-bottom: 4px; }
174
+ .modal-size { color: var(--text-dim); font-size: 13px; margin-bottom: 24px; }
175
+ .modal-buttons { display: flex; gap: 12px; justify-content: center; }
176
+ .btn-accept {
177
+ background: var(--green); color: #0f0f1a; padding: 10px 32px;
178
+ border-radius: var(--radius); border: none; cursor: pointer;
179
+ font-size: 14px; font-weight: 600; transition: all 0.12s;
180
+ }
181
+ .btn-accept:hover { background: var(--green-dim); }
182
+ .btn-reject {
183
+ background: var(--red); color: #fff; padding: 10px 32px;
184
+ border-radius: var(--radius); border: none; cursor: pointer;
185
+ font-size: 14px; font-weight: 600; transition: all 0.12s;
186
+ }
187
+ .btn-reject:hover { opacity: 0.85; }
188
+
189
+ /* ── Scrollbar ── */
190
+ ::-webkit-scrollbar { width: 6px; }
191
+ ::-webkit-scrollbar-track { background: transparent; }
192
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
193
+ ::-webkit-scrollbar-thumb:hover { background: #3a3a55; }
194
+
195
+ /* ── Connection status ── */
196
+ .conn-status {
197
+ position: fixed; bottom: 12px; right: 16px;
198
+ font-size: 11px; color: var(--text-dim); opacity: 0.6;
199
+ }
200
+ </style>
201
+ </head>
202
+ <body>
203
+
204
+ <!-- Header -->
205
+ <div class="header">
206
+ <div class="header-left">
207
+ <div class="logo">&#8652; LAN Transfer</div>
208
+ <div class="device-info" id="device-info"></div>
209
+ </div>
210
+ <div class="toggle" id="toggle" onclick="api('toggle')">
211
+ <div class="toggle-dot"></div>
212
+ <span id="toggle-text">Hidden</span>
213
+ </div>
214
+ </div>
215
+
216
+ <!-- Main -->
217
+ <div class="main">
218
+ <!-- Sidebar: peers -->
219
+ <div class="sidebar">
220
+ <div class="section-title">Nearby Devices</div>
221
+ <div class="peers" id="peers"></div>
222
+ </div>
223
+
224
+ <!-- Content -->
225
+ <div class="content">
226
+ <!-- Send panel -->
227
+ <div class="send-panel">
228
+ <div class="send-title">Send File</div>
229
+ <div class="send-row">
230
+ <button class="btn btn-browse" onclick="api('browse')">Browse</button>
231
+ <span class="file-info" id="file-info">No file selected</span>
232
+ </div>
233
+ <div class="send-row" style="margin-top: 12px;">
234
+ <button class="btn btn-send" id="send-btn" onclick="doSend()" disabled>Send</button>
235
+ </div>
236
+ </div>
237
+
238
+ <!-- Transfers -->
239
+ <div class="transfers-section">
240
+ <div class="section-title" style="padding: 0 0 12px;">Transfers</div>
241
+ <div id="transfers"></div>
242
+ </div>
243
+ </div>
244
+ </div>
245
+
246
+ <!-- Incoming request modal -->
247
+ <div class="modal-overlay" id="modal-overlay">
248
+ <div class="modal" id="modal"></div>
249
+ </div>
250
+
251
+ <!-- Connection status -->
252
+ <div class="conn-status" id="conn-status"></div>
253
+
254
+ <script>
255
+ // ──────────────────────────────────────────────────────────────────────────
256
+ // State
257
+ // ──────────────────────────────────────────────────────────────────────────
258
+ let state = {};
259
+ let selectedPeerId = null;
260
+
261
+ // ──────────────────────────────────────────────────────────────────────────
262
+ // SSE connection
263
+ // ──────────────────────────────────────────────────────────────────────────
264
+ let evtSource = null;
265
+ function connectSSE() {
266
+ evtSource = new EventSource('/events');
267
+ evtSource.onmessage = e => {
268
+ state = JSON.parse(e.data);
269
+ render();
270
+ };
271
+ evtSource.onopen = () => {
272
+ document.getElementById('conn-status').textContent = '';
273
+ };
274
+ evtSource.onerror = () => {
275
+ document.getElementById('conn-status').textContent = 'Reconnecting...';
276
+ };
277
+ }
278
+ connectSSE();
279
+
280
+ // ──────────────────────────────────────────────────────────────────────────
281
+ // API
282
+ // ──────────────────────────────────────────────────────────────────────────
283
+ function api(endpoint, data) {
284
+ fetch('/api/' + endpoint, {
285
+ method: 'POST',
286
+ headers: { 'Content-Type': 'application/json' },
287
+ body: JSON.stringify(data || {}),
288
+ });
289
+ }
290
+
291
+ function selectPeer(id) {
292
+ selectedPeerId = (selectedPeerId === id) ? null : id;
293
+ render();
294
+ }
295
+
296
+ function doSend() {
297
+ if (selectedPeerId && state.selectedFile) {
298
+ api('send', { peerId: selectedPeerId });
299
+ }
300
+ }
301
+
302
+ function respondTransfer(requestId, accepted) {
303
+ api('respond', { requestId: requestId, accepted: accepted });
304
+ }
305
+
306
+ // ──────────────────────────────────────────────────────────────────────────
307
+ // Render
308
+ // ──────────────────────────────────────────────────────────────────────────
309
+ function render() {
310
+ // Header
311
+ const info = document.getElementById('device-info');
312
+ info.textContent = (state.deviceName || '') + ' \u00B7 ' + (state.localIP || '');
313
+
314
+ // Toggle
315
+ const toggle = document.getElementById('toggle');
316
+ const toggleText = document.getElementById('toggle-text');
317
+ if (state.isDiscoverable) {
318
+ toggle.classList.add('active');
319
+ toggleText.textContent = 'Discoverable';
320
+ } else {
321
+ toggle.classList.remove('active');
322
+ toggleText.textContent = 'Hidden';
323
+ }
324
+
325
+ // Peers
326
+ const peersEl = document.getElementById('peers');
327
+ if (!state.peers || state.peers.length === 0) {
328
+ peersEl.innerHTML = '<div class="empty-msg">Searching for nearby devices&hellip;<br><br><small>Make sure both devices are on the same WiFi</small></div>';
329
+ } else {
330
+ // Validate selection still exists
331
+ if (selectedPeerId && !state.peers.some(p => p.id === selectedPeerId)) {
332
+ selectedPeerId = null;
333
+ }
334
+ peersEl.innerHTML = state.peers.map(p =>
335
+ '<div class="peer' + (selectedPeerId === p.id ? ' selected' : '') + '" onclick="selectPeer(\'' + esc(p.id) + '\')">'
336
+ + '<div class="peer-name">' + esc(p.name) + '</div>'
337
+ + '<div class="peer-ip">' + esc(p.ip) + '</div>'
338
+ + '</div>'
339
+ ).join('');
340
+ }
341
+
342
+ // File info
343
+ const fileInfoEl = document.getElementById('file-info');
344
+ if (state.selectedFile) {
345
+ fileInfoEl.className = 'file-info has-file';
346
+ fileInfoEl.textContent = state.selectedFile.name + ' \u00B7 ' + formatSize(state.selectedFile.size);
347
+ } else {
348
+ fileInfoEl.className = 'file-info';
349
+ fileInfoEl.textContent = 'No file selected';
350
+ }
351
+
352
+ // Send button
353
+ const sendBtn = document.getElementById('send-btn');
354
+ const canSend = !!(selectedPeerId && state.selectedFile && state.peers && state.peers.some(p => p.id === selectedPeerId));
355
+ sendBtn.disabled = !canSend;
356
+ if (canSend) {
357
+ const peer = state.peers.find(p => p.id === selectedPeerId);
358
+ sendBtn.textContent = 'Send to ' + (peer ? peer.name : 'peer');
359
+ } else if (selectedPeerId && !state.selectedFile) {
360
+ sendBtn.textContent = 'Select a file first';
361
+ } else if (!selectedPeerId && state.selectedFile) {
362
+ sendBtn.textContent = 'Select a device first';
363
+ } else {
364
+ sendBtn.textContent = 'Send';
365
+ }
366
+
367
+ // Transfers
368
+ const transfersEl = document.getElementById('transfers');
369
+ if (!state.transfers || state.transfers.length === 0) {
370
+ transfersEl.innerHTML = '<div class="empty-msg">No transfers yet</div>';
371
+ } else {
372
+ transfersEl.innerHTML = state.transfers.slice().reverse().map(renderTransfer).join('');
373
+ }
374
+
375
+ // Modal
376
+ const overlay = document.getElementById('modal-overlay');
377
+ const modal = document.getElementById('modal');
378
+ if (state.incomingRequests && state.incomingRequests.length > 0) {
379
+ const req = state.incomingRequests[0];
380
+ overlay.classList.add('show');
381
+ modal.innerHTML =
382
+ '<div class="modal-title">Incoming File</div>'
383
+ + '<div class="modal-sender">' + esc(req.senderName) + ' wants to send you:</div>'
384
+ + '<div class="modal-file">' + esc(req.filename) + '</div>'
385
+ + '<div class="modal-size">' + formatSize(req.size) + '</div>'
386
+ + '<div class="modal-buttons">'
387
+ + '<button class="btn-accept" onclick="respondTransfer(\'' + esc(req.id) + '\', true)">Accept</button>'
388
+ + '<button class="btn-reject" onclick="respondTransfer(\'' + esc(req.id) + '\', false)">Reject</button>'
389
+ + '</div>';
390
+ } else {
391
+ overlay.classList.remove('show');
392
+ }
393
+ }
394
+
395
+ function renderTransfer(t) {
396
+ const icon = t.direction === 'send' ? '\u2B06' : '\u2B07';
397
+ const arrow = t.direction === 'send' ? '\u2192' : '\u2190';
398
+ const peer = t.peerName || 'Unknown';
399
+
400
+ let badgeClass, badgeText;
401
+ switch (t.status) {
402
+ case 'completed': badgeClass = 'badge-completed'; badgeText = '\u2713 Done'; break;
403
+ case 'failed': badgeClass = 'badge-failed'; badgeText = '\u2717 ' + (t.error || 'Failed'); break;
404
+ case 'waiting': badgeClass = 'badge-waiting'; badgeText = 'Waiting\u2026'; break;
405
+ default: badgeClass = 'badge-progress'; badgeText = t.progress + '%'; break;
406
+ }
407
+
408
+ let extra = '';
409
+ if (t.status === 'in_progress') {
410
+ extra =
411
+ '<div class="progress-track"><div class="progress-fill" style="width:' + t.progress + '%"></div></div>'
412
+ + '<div class="transfer-meta">'
413
+ + '<span>' + formatSize(t.size) + '</span>'
414
+ + '<span>' + formatSpeed(t.speed) + '</span>'
415
+ + '</div>';
416
+ }
417
+
418
+ return '<div class="transfer">'
419
+ + '<div class="transfer-top">'
420
+ + '<div class="transfer-info">'
421
+ + '<span class="transfer-icon">' + icon + '</span>'
422
+ + '<span>' + esc(t.filename) + ' ' + arrow + ' ' + esc(peer) + '</span>'
423
+ + '</div>'
424
+ + '<span class="badge ' + badgeClass + '">' + badgeText + '</span>'
425
+ + '</div>'
426
+ + extra
427
+ + '</div>';
428
+ }
429
+
430
+ // ──────────────────────────────────────────────────────────────────────────
431
+ // Utilities
432
+ // ──────────────────────────────────────────────────────────────────────────
433
+ function esc(s) {
434
+ if (!s) return '';
435
+ return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
436
+ }
437
+
438
+ function formatSize(bytes) {
439
+ if (!bytes || bytes <= 0) return '0 B';
440
+ if (bytes < 1024) return bytes + ' B';
441
+ if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
442
+ if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
443
+ return (bytes / 1073741824).toFixed(1) + ' GB';
444
+ }
445
+
446
+ function formatSpeed(bps) {
447
+ if (!bps || bps <= 0) return '';
448
+ if (bps < 1024) return bps.toFixed(0) + ' B/s';
449
+ if (bps < 1048576) return (bps / 1024).toFixed(1) + ' KB/s';
450
+ return (bps / 1048576).toFixed(1) + ' MB/s';
451
+ }
452
+ </script>
453
+ </body>
454
+ </html>
package/server.js ADDED
@@ -0,0 +1,506 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * server.js — LAN File Transfer (Node.js rewrite)
5
+ *
6
+ * Zero external dependencies. Uses built-in modules only.
7
+ * Protocol-compatible with the original Rust version:
8
+ * - UDP discovery on port 34254
9
+ * - TCP file transfer on port 34255
10
+ * - XOR obfuscation with key "LAN-XFER-KEY-2024"
11
+ *
12
+ * Run: node server.js
13
+ * Then open http://localhost:3000 in a browser.
14
+ */
15
+
16
+ const http = require('http');
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const dgram = require('dgram');
20
+ const net = require('net');
21
+ const os = require('os');
22
+ const { exec } = require('child_process');
23
+
24
+ // ──────────────────────────────────────────────────────────────────────────────
25
+ // Configuration
26
+ // ──────────────────────────────────────────────────────────────────────────────
27
+ const DISCOVERY_PORT = 34254;
28
+ const TRANSFER_PORT = 34255;
29
+ const HTTP_PORT = 3000;
30
+ const XOR_KEY = Buffer.from('LAN-XFER-KEY-2024');
31
+ const PEER_TIMEOUT = 10_000; // ms
32
+ const BROADCAST_INTERVAL = 3_000; // ms
33
+
34
+ // ──────────────────────────────────────────────────────────────────────────────
35
+ // Application state
36
+ // ──────────────────────────────────────────────────────────────────────────────
37
+ let isDiscoverable = true;
38
+ const deviceName = os.hostname();
39
+ let selectedFile = null; // { path, name, size }
40
+ const peers = new Map(); // id → { device_name, ip, tcp_port, last_seen }
41
+ const transfers = []; // [ TransferEntry ]
42
+ const pendingRequests = new Map(); // id → { filename, size, senderName, resolve }
43
+ let idCounter = 0;
44
+
45
+ // ──────────────────────────────────────────────────────────────────────────────
46
+ // SSE (Server-Sent Events) clients
47
+ // ──────────────────────────────────────────────────────────────────────────────
48
+ const sseClients = new Set();
49
+
50
+ function getLocalIP() {
51
+ for (const ifaces of Object.values(os.networkInterfaces())) {
52
+ for (const iface of ifaces) {
53
+ if (iface.family === 'IPv4' && !iface.internal) return iface.address;
54
+ }
55
+ }
56
+ return '127.0.0.1';
57
+ }
58
+
59
+ function serializeState() {
60
+ return JSON.stringify({
61
+ isDiscoverable,
62
+ deviceName,
63
+ localIP: getLocalIP(),
64
+ peers: [...peers.entries()].map(([id, p]) => ({
65
+ id, name: p.device_name, ip: p.ip,
66
+ })),
67
+ selectedFile: selectedFile
68
+ ? { name: selectedFile.name, size: selectedFile.size }
69
+ : null,
70
+ transfers: transfers.map(t => ({
71
+ id: t.id, filename: t.filename, size: t.size,
72
+ direction: t.direction, peerName: t.peerName,
73
+ status: t.status, progress: t.progress,
74
+ speed: t.speed, error: t.error,
75
+ })),
76
+ incomingRequests: [...pendingRequests.entries()].map(([id, r]) => ({
77
+ id, filename: r.filename, size: r.size, senderName: r.senderName,
78
+ })),
79
+ });
80
+ }
81
+
82
+ function broadcast() {
83
+ const msg = `data: ${serializeState()}\n\n`;
84
+ for (const res of sseClients) {
85
+ try { res.write(msg); } catch { /* client gone */ }
86
+ }
87
+ }
88
+
89
+ // ──────────────────────────────────────────────────────────────────────────────
90
+ // Helpers
91
+ // ──────────────────────────────────────────────────────────────────────────────
92
+ function xorCrypt(buf, offset) {
93
+ const out = Buffer.allocUnsafe(buf.length);
94
+ for (let i = 0; i < buf.length; i++) {
95
+ out[i] = buf[i] ^ XOR_KEY[(offset + i) % XOR_KEY.length];
96
+ }
97
+ return out;
98
+ }
99
+
100
+ function readBody(req) {
101
+ return new Promise(resolve => {
102
+ let body = '';
103
+ req.on('data', c => body += c);
104
+ req.on('end', () => resolve(body));
105
+ });
106
+ }
107
+
108
+ /** Read exactly `n` bytes from a socket. */
109
+ function readExact(socket, n) {
110
+ return new Promise((resolve, reject) => {
111
+ let buf = Buffer.alloc(0);
112
+ const onData = chunk => {
113
+ buf = Buffer.concat([buf, chunk]);
114
+ if (buf.length >= n) {
115
+ cleanup();
116
+ if (buf.length > n) socket.unshift(buf.subarray(n));
117
+ resolve(buf.subarray(0, n));
118
+ }
119
+ };
120
+ const onErr = err => { cleanup(); reject(err); };
121
+ const onEnd = () => { cleanup(); reject(new Error('Disconnected')); };
122
+ const onClose = () => { cleanup(); reject(new Error('Connection closed')); };
123
+ function cleanup() {
124
+ socket.off('data', onData);
125
+ socket.off('error', onErr);
126
+ socket.off('end', onEnd);
127
+ socket.off('close', onClose);
128
+ }
129
+ socket.on('data', onData);
130
+ socket.on('error', onErr);
131
+ socket.on('end', onEnd);
132
+ socket.on('close', onClose);
133
+ });
134
+ }
135
+
136
+ // ──────────────────────────────────────────────────────────────────────────────
137
+ // HTTP server (serves UI + SSE + API)
138
+ // ──────────────────────────────────────────────────────────────────────────────
139
+ const htmlPath = path.join(__dirname, 'public', 'index.html');
140
+
141
+ const server = http.createServer(async (req, res) => {
142
+ // ── Serve the web UI ──
143
+ if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {
144
+ fs.readFile(htmlPath, (err, data) => {
145
+ if (err) { res.writeHead(500); res.end('Error loading UI'); return; }
146
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
147
+ res.end(data);
148
+ });
149
+ return;
150
+ }
151
+
152
+ // ── SSE stream ──
153
+ if (req.method === 'GET' && req.url === '/events') {
154
+ res.writeHead(200, {
155
+ 'Content-Type': 'text/event-stream',
156
+ 'Cache-Control': 'no-cache',
157
+ 'Connection': 'keep-alive',
158
+ });
159
+ sseClients.add(res);
160
+ req.on('close', () => sseClients.delete(res));
161
+ res.write(`data: ${serializeState()}\n\n`);
162
+ return;
163
+ }
164
+
165
+ // ── API endpoints ──
166
+ if (req.method === 'POST') {
167
+ const body = await readBody(req);
168
+ let data = {};
169
+ try { data = JSON.parse(body); } catch { /* empty body is fine */ }
170
+
171
+ if (req.url === '/api/toggle') {
172
+ isDiscoverable = !isDiscoverable;
173
+ }
174
+ else if (req.url === '/api/browse') {
175
+ const fp = await openFileDialog();
176
+ if (fp) {
177
+ try {
178
+ const s = fs.statSync(fp);
179
+ selectedFile = { path: fp, name: path.basename(fp), size: s.size };
180
+ } catch { selectedFile = null; }
181
+ }
182
+ }
183
+ else if (req.url === '/api/send') {
184
+ const peer = peers.get(data.peerId);
185
+ if (peer && selectedFile) {
186
+ sendFile(peer, { ...selectedFile });
187
+ }
188
+ }
189
+ else if (req.url === '/api/respond') {
190
+ const pending = pendingRequests.get(data.requestId);
191
+ if (pending) {
192
+ pending.resolve(!!data.accepted);
193
+ pendingRequests.delete(data.requestId);
194
+ }
195
+ }
196
+
197
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
198
+ res.end('ok');
199
+ broadcast();
200
+ return;
201
+ }
202
+
203
+ res.writeHead(404);
204
+ res.end('Not Found');
205
+ });
206
+
207
+ // ──────────────────────────────────────────────────────────────────────────────
208
+ // Native file dialog
209
+ // ──────────────────────────────────────────────────────────────────────────────
210
+ function openFileDialog() {
211
+ return new Promise(resolve => {
212
+ let cmd;
213
+ if (process.platform === 'win32') {
214
+ cmd = 'powershell -sta -command "Add-Type -AssemblyName System.Windows.Forms; $f = New-Object System.Windows.Forms.OpenFileDialog; $f.Title = \'Select file to send\'; if($f.ShowDialog() -eq \'OK\'){Write-Output $f.FileName}"';
215
+ } else if (process.platform === 'darwin') {
216
+ cmd = 'osascript -e \'POSIX path of (choose file with prompt "Select file to send")\'';
217
+ } else {
218
+ cmd = 'zenity --file-selection --title="Select file to send" 2>/dev/null || kdialog --getopenfilename . 2>/dev/null';
219
+ }
220
+ exec(cmd, { encoding: 'utf-8', windowsHide: true, timeout: 120_000 }, (err, stdout) => {
221
+ resolve(err ? null : (stdout.trim() || null));
222
+ });
223
+ });
224
+ }
225
+
226
+ // ──────────────────────────────────────────────────────────────────────────────
227
+ // UDP discovery
228
+ // ──────────────────────────────────────────────────────────────────────────────
229
+ const udp = dgram.createSocket({ type: 'udp4', reuseAddr: true });
230
+
231
+ udp.on('listening', () => {
232
+ udp.setBroadcast(true);
233
+ console.log(` Discovery UDP :${DISCOVERY_PORT}`);
234
+ });
235
+
236
+ udp.on('message', (msg, rinfo) => {
237
+ try {
238
+ const d = JSON.parse(msg.toString());
239
+ if (d.device_name === deviceName) return; // ignore self
240
+ const id = `${rinfo.address}:${d.tcp_port}`;
241
+ const isNew = !peers.has(id);
242
+ peers.set(id, {
243
+ device_name: d.device_name,
244
+ ip: rinfo.address,
245
+ tcp_port: d.tcp_port,
246
+ last_seen: Date.now(),
247
+ });
248
+ if (isNew) broadcast();
249
+ } catch { /* ignore malformed packets */ }
250
+ });
251
+
252
+ udp.on('error', err => {
253
+ console.error(` UDP error: ${err.message}`);
254
+ console.error(' Is another instance running? (port conflict on UDP ' + DISCOVERY_PORT + ')');
255
+ });
256
+
257
+ udp.bind(DISCOVERY_PORT);
258
+
259
+ // Broadcaster — send beacon every 3 s
260
+ setInterval(() => {
261
+ if (!isDiscoverable) return;
262
+ const msg = JSON.stringify({ device_name: deviceName, tcp_port: TRANSFER_PORT });
263
+ udp.send(msg, DISCOVERY_PORT, '255.255.255.255', () => {});
264
+ }, BROADCAST_INTERVAL);
265
+
266
+ // Prune stale peers
267
+ setInterval(() => {
268
+ let changed = false;
269
+ for (const [id, p] of peers) {
270
+ if (Date.now() - p.last_seen > PEER_TIMEOUT) { peers.delete(id); changed = true; }
271
+ }
272
+ if (changed) broadcast();
273
+ }, 2000);
274
+
275
+ // ──────────────────────────────────────────────────────────────────────────────
276
+ // TCP file transfer — receiver
277
+ // ──────────────────────────────────────────────────────────────────────────────
278
+ const tcpServer = net.createServer(socket => {
279
+ handleIncoming(socket).catch(err => {
280
+ console.error(` Receive error: ${err.message}`);
281
+ });
282
+ });
283
+
284
+ tcpServer.on('error', err => {
285
+ console.error(` TCP error: ${err.message}`);
286
+ console.error(' Is another instance running? (port conflict on TCP ' + TRANSFER_PORT + ')');
287
+ });
288
+
289
+ tcpServer.listen(TRANSFER_PORT, () => {
290
+ console.log(` Transfer TCP :${TRANSFER_PORT}`);
291
+ });
292
+
293
+ async function handleIncoming(socket) {
294
+ // Read header length (8 bytes, little-endian u64)
295
+ const lenBuf = await readExact(socket, 8);
296
+ const headerLen = Number(lenBuf.readBigUInt64LE(0));
297
+
298
+ // Read header JSON
299
+ const headerBuf = await readExact(socket, headerLen);
300
+ const header = JSON.parse(headerBuf.toString('utf-8'));
301
+ const { filename, size, sender_name } = header;
302
+
303
+ console.log(` Incoming: ${filename} (${formatSize(size)}) from ${sender_name}`);
304
+
305
+ // Prompt user for accept / reject
306
+ const reqId = String(idCounter++);
307
+ const accepted = await new Promise(resolve => {
308
+ pendingRequests.set(reqId, { filename, size, senderName: sender_name, resolve });
309
+ broadcast();
310
+
311
+ // Auto-reject if sender disconnects while waiting
312
+ socket.once('close', () => {
313
+ if (pendingRequests.has(reqId)) {
314
+ pendingRequests.delete(reqId);
315
+ resolve(false);
316
+ broadcast();
317
+ }
318
+ });
319
+ });
320
+
321
+ // Send decision byte
322
+ socket.write(Buffer.from([accepted ? 1 : 0]));
323
+ if (!accepted) { socket.end(); return; }
324
+
325
+ // Create transfer entry
326
+ const tid = String(idCounter++);
327
+ const t = {
328
+ id: tid, filename, size, direction: 'receive', peerName: sender_name,
329
+ status: 'in_progress', progress: 0, speed: 0, error: null,
330
+ _start: Date.now(), _bytes: 0,
331
+ };
332
+ transfers.push(t);
333
+ broadcast();
334
+
335
+ // Unique save path in Downloads
336
+ const dl = path.join(os.homedir(), 'Downloads');
337
+ if (!fs.existsSync(dl)) fs.mkdirSync(dl, { recursive: true });
338
+ let savePath = path.join(dl, filename);
339
+ let counter = 1;
340
+ const ext = path.extname(filename);
341
+ const base = path.basename(filename, ext);
342
+ while (fs.existsSync(savePath)) {
343
+ savePath = path.join(dl, `${base} (${counter++})${ext}`);
344
+ }
345
+
346
+ const ws = fs.createWriteStream(savePath);
347
+ let received = 0;
348
+ let lastBc = 0;
349
+
350
+ socket.on('data', chunk => {
351
+ const decrypted = xorCrypt(chunk, received);
352
+ ws.write(decrypted);
353
+ received += chunk.length;
354
+
355
+ const now = Date.now();
356
+ if (now - lastBc > 300 || received >= size) {
357
+ t._bytes = received;
358
+ t.progress = Math.min(100, Math.round(received / size * 100));
359
+ const elapsed = (now - t._start) / 1000;
360
+ t.speed = elapsed > 0 ? received / elapsed : 0;
361
+ broadcast();
362
+ lastBc = now;
363
+ }
364
+ });
365
+
366
+ await new Promise((resolve, reject) => {
367
+ socket.on('end', () => {
368
+ ws.end();
369
+ t.status = 'completed';
370
+ t.progress = 100;
371
+ broadcast();
372
+ console.log(` Saved: ${savePath}`);
373
+ resolve();
374
+ });
375
+ socket.on('error', err => {
376
+ ws.end();
377
+ t.status = 'failed';
378
+ t.error = err.message;
379
+ broadcast();
380
+ reject(err);
381
+ });
382
+ });
383
+ }
384
+
385
+ // ──────────────────────────────────────────────────────────────────────────────
386
+ // TCP file transfer — sender
387
+ // ──────────────────────────────────────────────────────────────────────────────
388
+ async function sendFile(peer, file) {
389
+ const tid = String(idCounter++);
390
+ const t = {
391
+ id: tid, filename: file.name, size: file.size, direction: 'send',
392
+ peerName: peer.device_name, status: 'waiting', progress: 0, speed: 0,
393
+ error: null, _start: Date.now(), _bytes: 0,
394
+ };
395
+ transfers.push(t);
396
+ broadcast();
397
+
398
+ try {
399
+ const socket = new net.Socket();
400
+ await new Promise((res, rej) => {
401
+ socket.connect(peer.tcp_port, peer.ip, res);
402
+ socket.once('error', rej);
403
+ });
404
+
405
+ // Send header
406
+ const headerJSON = JSON.stringify({
407
+ filename: file.name, size: file.size, sender_name: deviceName,
408
+ });
409
+ const headerBuf = Buffer.from(headerJSON, 'utf-8');
410
+ const lenBuf = Buffer.alloc(8);
411
+ lenBuf.writeBigUInt64LE(BigInt(headerBuf.length));
412
+ socket.write(lenBuf);
413
+ socket.write(headerBuf);
414
+
415
+ // Wait for accept/reject
416
+ const resp = await readExact(socket, 1);
417
+ if (resp[0] !== 1) {
418
+ t.status = 'failed';
419
+ t.error = 'Rejected by recipient';
420
+ broadcast();
421
+ socket.end();
422
+ return;
423
+ }
424
+
425
+ t.status = 'in_progress';
426
+ t._start = Date.now();
427
+ broadcast();
428
+
429
+ // Stream file with XOR encryption
430
+ const rs = fs.createReadStream(file.path, { highWaterMark: 65536 });
431
+ let sent = 0;
432
+ let lastBc = 0;
433
+
434
+ for await (const chunk of rs) {
435
+ const encrypted = xorCrypt(Buffer.from(chunk), sent);
436
+ const ok = socket.write(encrypted);
437
+ sent += chunk.length;
438
+
439
+ // Backpressure
440
+ if (!ok) {
441
+ await new Promise((resolve, reject) => {
442
+ socket.once('drain', resolve);
443
+ socket.once('error', reject);
444
+ });
445
+ }
446
+
447
+ const now = Date.now();
448
+ if (now - lastBc > 300 || sent >= file.size) {
449
+ t._bytes = sent;
450
+ t.progress = Math.min(100, Math.round(sent / file.size * 100));
451
+ const elapsed = (now - t._start) / 1000;
452
+ t.speed = elapsed > 0 ? sent / elapsed : 0;
453
+ broadcast();
454
+ lastBc = now;
455
+ }
456
+ }
457
+
458
+ socket.end();
459
+ t.status = 'completed';
460
+ t.progress = 100;
461
+ broadcast();
462
+ console.log(` Sent: ${file.name} → ${peer.device_name}`);
463
+ } catch (e) {
464
+ t.status = 'failed';
465
+ t.error = e.message;
466
+ broadcast();
467
+ console.error(` Send error: ${e.message}`);
468
+ }
469
+ }
470
+
471
+ // ──────────────────────────────────────────────────────────────────────────────
472
+ // Utility
473
+ // ──────────────────────────────────────────────────────────────────────────────
474
+ function formatSize(bytes) {
475
+ if (bytes < 1024) return bytes + ' B';
476
+ if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
477
+ if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
478
+ return (bytes / 1073741824).toFixed(1) + ' GB';
479
+ }
480
+
481
+ // ──────────────────────────────────────────────────────────────────────────────
482
+ // Start
483
+ // ──────────────────────────────────────────────────────────────────────────────
484
+ server.on('error', err => {
485
+ console.error(` HTTP error: ${err.message}`);
486
+ if (err.code === 'EADDRINUSE') {
487
+ console.error(` Port ${HTTP_PORT} is in use. Close the other app or change HTTP_PORT.`);
488
+ process.exit(1);
489
+ }
490
+ });
491
+
492
+ server.listen(HTTP_PORT, () => {
493
+ const ip = getLocalIP();
494
+ console.log('');
495
+ console.log(' ⇄ LAN File Transfer');
496
+ console.log(` Device: ${deviceName}`);
497
+ console.log(` Local IP: ${ip}`);
498
+ console.log(` UI: http://localhost:${HTTP_PORT}`);
499
+ console.log('');
500
+
501
+ // Auto-open browser
502
+ const url = `http://localhost:${HTTP_PORT}`;
503
+ if (process.platform === 'win32') exec(`start "" "${url}"`);
504
+ else if (process.platform === 'darwin') exec(`open "${url}"`);
505
+ else exec(`xdg-open "${url}"`);
506
+ });