@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.
- package/package.json +34 -0
- package/public/index.html +454 -0
- 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">⇌ 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…<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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
|
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
|
+
});
|