@balaji003/lantransfer 1.0.5 → 1.0.7
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/README.md +70 -0
- package/package.json +3 -2
- package/public/index.html +160 -20
- package/server.js +122 -19
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# lantransfer
|
|
2
|
+
|
|
3
|
+
Peer-to-peer file sharing over your local network. Zero dependencies. Works on Windows, macOS, and Linux.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
npm install -g @balaji003/lantransfer
|
|
7
|
+
lantransfer
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
A browser window opens automatically — select a file, pick a nearby device, and send.
|
|
11
|
+
|
|
12
|
+
## Features
|
|
13
|
+
|
|
14
|
+
- **Zero dependencies** — uses only Node.js built-in modules
|
|
15
|
+
- **Auto-discovery** — finds devices on your LAN automatically via HTTP subnet scanning (no firewall rules needed) + UDP broadcast fallback
|
|
16
|
+
- **Browser UI** — clean dark-themed interface, no extra apps to install
|
|
17
|
+
- **Cross-platform** — Windows, macOS, Linux
|
|
18
|
+
- **Accept/Reject prompt** — incoming files must be approved before transfer
|
|
19
|
+
- **Progress tracking** — real-time speed and progress for all transfers
|
|
20
|
+
- **XOR encrypted** — file data is obfuscated during transfer
|
|
21
|
+
- **Native file picker** — uses your OS file dialog (PowerShell on Windows, osascript on macOS, zenity/kdialog on Linux)
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
### Install globally and run
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install -g @balaji003/lantransfer
|
|
29
|
+
lantransfer
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Or run directly with npx
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npx @balaji003/lantransfer
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Or clone and run
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
git clone https://github.com/user/lantransfer.git
|
|
42
|
+
cd lantransfer
|
|
43
|
+
node server.js
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## How it works
|
|
47
|
+
|
|
48
|
+
1. Run `lantransfer` on two (or more) devices connected to the same WiFi/LAN
|
|
49
|
+
2. Each device scans the local subnet to find other instances — no firewall configuration needed
|
|
50
|
+
3. Devices appear in the sidebar under "Nearby Devices"
|
|
51
|
+
4. Click **Browse** to select a file, select a device, and click **Send**
|
|
52
|
+
5. The receiver sees a popup to **Accept** or **Reject**
|
|
53
|
+
6. Accepted files are saved to the `Downloads` folder
|
|
54
|
+
|
|
55
|
+
## Ports
|
|
56
|
+
|
|
57
|
+
| Port | Protocol | Purpose |
|
|
58
|
+
|-------|----------|----------------------|
|
|
59
|
+
| 3000 | HTTP | Web UI + discovery |
|
|
60
|
+
| 34254 | UDP | Broadcast discovery (fallback) |
|
|
61
|
+
| 34255 | TCP | File transfer |
|
|
62
|
+
|
|
63
|
+
## Requirements
|
|
64
|
+
|
|
65
|
+
- Node.js >= 16.0.0
|
|
66
|
+
- Both devices on the same local network
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@balaji003/lantransfer",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "LAN File Transfer — peer-to-peer file sharing over local network. Zero dependencies.",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"server.js",
|
|
11
11
|
"public/",
|
|
12
|
-
"bin/"
|
|
12
|
+
"bin/",
|
|
13
|
+
"README.md"
|
|
13
14
|
],
|
|
14
15
|
"scripts": {
|
|
15
16
|
"start": "node server.js"
|
package/public/index.html
CHANGED
|
@@ -47,7 +47,14 @@
|
|
|
47
47
|
}
|
|
48
48
|
.header-left { display: flex; align-items: center; gap: 16px; }
|
|
49
49
|
.logo { font-size: 20px; font-weight: 700; color: #8cc8ff; }
|
|
50
|
-
.device-info { color: var(--text-dim); font-size: 13px; }
|
|
50
|
+
.device-info { color: var(--text-dim); font-size: 13px; display: flex; align-items: center; gap: 6px; }
|
|
51
|
+
.device-name-edit {
|
|
52
|
+
background: transparent; border: 1px solid transparent; color: var(--text);
|
|
53
|
+
font-size: 13px; font-family: inherit; padding: 2px 6px; border-radius: 4px;
|
|
54
|
+
width: 160px; outline: none; transition: border-color 0.2s;
|
|
55
|
+
}
|
|
56
|
+
.device-name-edit:hover { border-color: var(--border); }
|
|
57
|
+
.device-name-edit:focus { border-color: var(--accent); background: var(--surface-hover); }
|
|
51
58
|
|
|
52
59
|
.toggle {
|
|
53
60
|
display: flex; align-items: center; gap: 8px;
|
|
@@ -135,6 +142,50 @@
|
|
|
135
142
|
.file-info { color: var(--text-dim); font-size: 13px; }
|
|
136
143
|
.file-info.has-file { color: var(--text); font-weight: 500; }
|
|
137
144
|
|
|
145
|
+
.file-list { margin-top: 12px; }
|
|
146
|
+
.file-item {
|
|
147
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
148
|
+
padding: 8px 12px; background: var(--surface); border-radius: var(--radius);
|
|
149
|
+
margin-bottom: 4px; font-size: 13px;
|
|
150
|
+
}
|
|
151
|
+
.file-item-left { display: flex; align-items: center; gap: 8px; overflow: hidden; }
|
|
152
|
+
.file-item-name { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
153
|
+
.file-item-size { color: var(--text-dim); white-space: nowrap; }
|
|
154
|
+
.file-item-radio { accent-color: var(--accent); cursor: pointer; }
|
|
155
|
+
.file-item-remove {
|
|
156
|
+
background: none; border: none; color: var(--text-dim); cursor: pointer;
|
|
157
|
+
font-size: 16px; padding: 0 4px; line-height: 1; transition: color 0.12s;
|
|
158
|
+
}
|
|
159
|
+
.file-item-remove:hover { color: var(--red); }
|
|
160
|
+
|
|
161
|
+
.transfer-actions {
|
|
162
|
+
display: flex; align-items: center; gap: 10px;
|
|
163
|
+
margin-top: 8px; font-size: 12px;
|
|
164
|
+
}
|
|
165
|
+
.transfer-size { color: var(--text-dim); }
|
|
166
|
+
.btn-open-folder {
|
|
167
|
+
background: none; border: 1px solid var(--border); color: var(--accent);
|
|
168
|
+
padding: 3px 10px; border-radius: 4px; cursor: pointer;
|
|
169
|
+
font-size: 12px; transition: all 0.12s;
|
|
170
|
+
}
|
|
171
|
+
.btn-open-folder:hover { background: var(--surface-hover); border-color: var(--accent); }
|
|
172
|
+
|
|
173
|
+
.download-dir {
|
|
174
|
+
display: flex; align-items: center; gap: 8px; padding: 8px 24px;
|
|
175
|
+
background: var(--surface); border-bottom: 1px solid var(--border);
|
|
176
|
+
font-size: 12px; color: var(--text-dim); flex-shrink: 0;
|
|
177
|
+
}
|
|
178
|
+
.download-dir-path {
|
|
179
|
+
color: var(--text); font-weight: 500; overflow: hidden;
|
|
180
|
+
text-overflow: ellipsis; white-space: nowrap; max-width: 400px;
|
|
181
|
+
}
|
|
182
|
+
.btn-change-dir {
|
|
183
|
+
background: none; border: 1px solid var(--border); color: var(--accent);
|
|
184
|
+
padding: 2px 8px; border-radius: 4px; cursor: pointer;
|
|
185
|
+
font-size: 11px; transition: all 0.12s; white-space: nowrap;
|
|
186
|
+
}
|
|
187
|
+
.btn-change-dir:hover { background: var(--surface-hover); border-color: var(--accent); }
|
|
188
|
+
|
|
138
189
|
/* ── Transfers ── */
|
|
139
190
|
.transfers-section { flex: 1; overflow-y: auto; padding: 16px 24px; }
|
|
140
191
|
.transfer {
|
|
@@ -216,7 +267,11 @@
|
|
|
216
267
|
<div class="header">
|
|
217
268
|
<div class="header-left">
|
|
218
269
|
<div class="logo">⇌ LAN Transfer</div>
|
|
219
|
-
<div class="device-info"
|
|
270
|
+
<div class="device-info">
|
|
271
|
+
<input class="device-name-edit" id="device-name" type="text" maxlength="50" title="Click to edit device name" />
|
|
272
|
+
<span style="color:var(--text-dim)">·</span>
|
|
273
|
+
<span id="local-ip"></span>
|
|
274
|
+
</div>
|
|
220
275
|
</div>
|
|
221
276
|
<div class="header-right">
|
|
222
277
|
<div class="toggle" id="toggle" onclick="api('toggle')">
|
|
@@ -229,6 +284,13 @@
|
|
|
229
284
|
</div>
|
|
230
285
|
</div>
|
|
231
286
|
|
|
287
|
+
<!-- Download location -->
|
|
288
|
+
<div class="download-dir">
|
|
289
|
+
<span>Save to:</span>
|
|
290
|
+
<span class="download-dir-path" id="download-dir" title=""></span>
|
|
291
|
+
<button class="btn-change-dir" onclick="api('set-download-dir')">Change</button>
|
|
292
|
+
</div>
|
|
293
|
+
|
|
232
294
|
<!-- Main -->
|
|
233
295
|
<div class="main">
|
|
234
296
|
<!-- Sidebar: peers -->
|
|
@@ -243,9 +305,10 @@
|
|
|
243
305
|
<div class="send-panel">
|
|
244
306
|
<div class="send-title">Send File</div>
|
|
245
307
|
<div class="send-row">
|
|
246
|
-
<button class="btn btn-browse" onclick="
|
|
247
|
-
<span class="file-info" id="file-info">No
|
|
308
|
+
<button class="btn btn-browse" id="browse-btn" onclick="doBrowse()">Browse</button>
|
|
309
|
+
<span class="file-info" id="file-info">No files added</span>
|
|
248
310
|
</div>
|
|
311
|
+
<div class="file-list" id="file-list"></div>
|
|
249
312
|
<div class="send-row" style="margin-top: 12px;">
|
|
250
313
|
<button class="btn btn-send" id="send-btn" onclick="doSend()" disabled>Send</button>
|
|
251
314
|
</div>
|
|
@@ -273,6 +336,8 @@
|
|
|
273
336
|
// ──────────────────────────────────────────────────────────────────────────
|
|
274
337
|
let state = {};
|
|
275
338
|
let selectedPeerId = null;
|
|
339
|
+
let selectedFileId = null;
|
|
340
|
+
let browsing = false;
|
|
276
341
|
|
|
277
342
|
// ──────────────────────────────────────────────────────────────────────────
|
|
278
343
|
// SSE connection
|
|
@@ -282,6 +347,10 @@
|
|
|
282
347
|
evtSource = new EventSource('/events');
|
|
283
348
|
evtSource.onmessage = e => {
|
|
284
349
|
state = JSON.parse(e.data);
|
|
350
|
+
// Auto-select the latest file if none selected
|
|
351
|
+
if (!selectedFileId && state.sharedFiles && state.sharedFiles.length > 0) {
|
|
352
|
+
selectedFileId = state.sharedFiles[state.sharedFiles.length - 1].id;
|
|
353
|
+
}
|
|
285
354
|
render();
|
|
286
355
|
};
|
|
287
356
|
evtSource.onopen = () => {
|
|
@@ -297,7 +366,7 @@
|
|
|
297
366
|
// API
|
|
298
367
|
// ──────────────────────────────────────────────────────────────────────────
|
|
299
368
|
function api(endpoint, data) {
|
|
300
|
-
fetch('/api/' + endpoint, {
|
|
369
|
+
return fetch('/api/' + endpoint, {
|
|
301
370
|
method: 'POST',
|
|
302
371
|
headers: { 'Content-Type': 'application/json' },
|
|
303
372
|
body: JSON.stringify(data || {}),
|
|
@@ -309,9 +378,28 @@
|
|
|
309
378
|
render();
|
|
310
379
|
}
|
|
311
380
|
|
|
381
|
+
function selectFile(id) {
|
|
382
|
+
selectedFileId = id;
|
|
383
|
+
render();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function removeFile(id) {
|
|
387
|
+
api('remove-file', { fileId: id });
|
|
388
|
+
if (selectedFileId === id) selectedFileId = null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function doBrowse() {
|
|
392
|
+
if (browsing) return;
|
|
393
|
+
browsing = true;
|
|
394
|
+
document.getElementById('browse-btn').textContent = 'Opening...';
|
|
395
|
+
await api('browse');
|
|
396
|
+
browsing = false;
|
|
397
|
+
document.getElementById('browse-btn').textContent = 'Browse';
|
|
398
|
+
}
|
|
399
|
+
|
|
312
400
|
function doSend() {
|
|
313
|
-
if (selectedPeerId &&
|
|
314
|
-
api('send', { peerId: selectedPeerId });
|
|
401
|
+
if (selectedPeerId && selectedFileId) {
|
|
402
|
+
api('send', { peerId: selectedPeerId, fileId: selectedFileId });
|
|
315
403
|
}
|
|
316
404
|
}
|
|
317
405
|
|
|
@@ -319,6 +407,10 @@
|
|
|
319
407
|
api('respond', { requestId: requestId, accepted: accepted });
|
|
320
408
|
}
|
|
321
409
|
|
|
410
|
+
function openFolder(filePath) {
|
|
411
|
+
api('open-folder', { path: filePath });
|
|
412
|
+
}
|
|
413
|
+
|
|
322
414
|
function doShutdown() {
|
|
323
415
|
if (confirm('Stop the server? This will close LAN Transfer.')) {
|
|
324
416
|
api('shutdown');
|
|
@@ -326,13 +418,34 @@
|
|
|
326
418
|
}
|
|
327
419
|
}
|
|
328
420
|
|
|
421
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
422
|
+
// Device name editing
|
|
423
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
424
|
+
const nameInput = document.getElementById('device-name');
|
|
425
|
+
let nameLoaded = false;
|
|
426
|
+
nameInput.addEventListener('change', () => {
|
|
427
|
+
const val = nameInput.value.trim();
|
|
428
|
+
if (val) api('rename', { name: val });
|
|
429
|
+
});
|
|
430
|
+
nameInput.addEventListener('keydown', e => {
|
|
431
|
+
if (e.key === 'Enter') nameInput.blur();
|
|
432
|
+
});
|
|
433
|
+
|
|
329
434
|
// ──────────────────────────────────────────────────────────────────────────
|
|
330
435
|
// Render
|
|
331
436
|
// ──────────────────────────────────────────────────────────────────────────
|
|
332
437
|
function render() {
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
|
|
438
|
+
// Device name (only set once to avoid clobbering user edits)
|
|
439
|
+
if (!nameLoaded && state.deviceName) {
|
|
440
|
+
nameInput.value = state.deviceName;
|
|
441
|
+
nameLoaded = true;
|
|
442
|
+
}
|
|
443
|
+
document.getElementById('local-ip').textContent = state.localIP || '';
|
|
444
|
+
|
|
445
|
+
// Download dir
|
|
446
|
+
const dirEl = document.getElementById('download-dir');
|
|
447
|
+
dirEl.textContent = state.downloadDir || '';
|
|
448
|
+
dirEl.title = state.downloadDir || '';
|
|
336
449
|
|
|
337
450
|
// Toggle
|
|
338
451
|
const toggle = document.getElementById('toggle');
|
|
@@ -350,7 +463,6 @@
|
|
|
350
463
|
if (!state.peers || state.peers.length === 0) {
|
|
351
464
|
peersEl.innerHTML = '<div class="empty-msg">Searching for nearby devices…<br><br><small>Make sure both devices are on the same WiFi</small></div>';
|
|
352
465
|
} else {
|
|
353
|
-
// Validate selection still exists
|
|
354
466
|
if (selectedPeerId && !state.peers.some(p => p.id === selectedPeerId)) {
|
|
355
467
|
selectedPeerId = null;
|
|
356
468
|
}
|
|
@@ -362,26 +474,47 @@
|
|
|
362
474
|
).join('');
|
|
363
475
|
}
|
|
364
476
|
|
|
365
|
-
//
|
|
477
|
+
// Shared files
|
|
478
|
+
const files = state.sharedFiles || [];
|
|
366
479
|
const fileInfoEl = document.getElementById('file-info');
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
} else {
|
|
480
|
+
const fileListEl = document.getElementById('file-list');
|
|
481
|
+
|
|
482
|
+
if (files.length === 0) {
|
|
371
483
|
fileInfoEl.className = 'file-info';
|
|
372
|
-
fileInfoEl.textContent = 'No
|
|
484
|
+
fileInfoEl.textContent = 'No files added';
|
|
485
|
+
fileListEl.innerHTML = '';
|
|
486
|
+
selectedFileId = null;
|
|
487
|
+
} else {
|
|
488
|
+
fileInfoEl.className = 'file-info has-file';
|
|
489
|
+
fileInfoEl.textContent = files.length + ' file' + (files.length > 1 ? 's' : '') + ' ready to send';
|
|
490
|
+
// Validate selection
|
|
491
|
+
if (selectedFileId && !files.some(f => f.id === selectedFileId)) {
|
|
492
|
+
selectedFileId = files[files.length - 1].id;
|
|
493
|
+
}
|
|
494
|
+
fileListEl.innerHTML = files.map(f =>
|
|
495
|
+
'<div class="file-item">'
|
|
496
|
+
+ '<div class="file-item-left">'
|
|
497
|
+
+ '<input type="radio" name="sel-file" class="file-item-radio" '
|
|
498
|
+
+ (selectedFileId === f.id ? 'checked ' : '')
|
|
499
|
+
+ 'onchange="selectFile(\'' + esc(f.id) + '\')" />'
|
|
500
|
+
+ '<span class="file-item-name">' + esc(f.name) + '</span>'
|
|
501
|
+
+ '<span class="file-item-size">' + formatSize(f.size) + '</span>'
|
|
502
|
+
+ '</div>'
|
|
503
|
+
+ '<button class="file-item-remove" onclick="removeFile(\'' + esc(f.id) + '\')" title="Remove">×</button>'
|
|
504
|
+
+ '</div>'
|
|
505
|
+
).join('');
|
|
373
506
|
}
|
|
374
507
|
|
|
375
508
|
// Send button
|
|
376
509
|
const sendBtn = document.getElementById('send-btn');
|
|
377
|
-
const canSend = !!(selectedPeerId &&
|
|
510
|
+
const canSend = !!(selectedPeerId && selectedFileId && state.peers && state.peers.some(p => p.id === selectedPeerId));
|
|
378
511
|
sendBtn.disabled = !canSend;
|
|
379
512
|
if (canSend) {
|
|
380
513
|
const peer = state.peers.find(p => p.id === selectedPeerId);
|
|
381
514
|
sendBtn.textContent = 'Send to ' + (peer ? peer.name : 'peer');
|
|
382
|
-
} else if (selectedPeerId && !
|
|
515
|
+
} else if (selectedPeerId && !selectedFileId) {
|
|
383
516
|
sendBtn.textContent = 'Select a file first';
|
|
384
|
-
} else if (!selectedPeerId &&
|
|
517
|
+
} else if (!selectedPeerId && selectedFileId) {
|
|
385
518
|
sendBtn.textContent = 'Select a device first';
|
|
386
519
|
} else {
|
|
387
520
|
sendBtn.textContent = 'Send';
|
|
@@ -436,6 +569,13 @@
|
|
|
436
569
|
+ '<span>' + formatSize(t.size) + '</span>'
|
|
437
570
|
+ '<span>' + formatSpeed(t.speed) + '</span>'
|
|
438
571
|
+ '</div>';
|
|
572
|
+
} else if (t.status === 'completed' || t.status === 'failed') {
|
|
573
|
+
extra = '<div class="transfer-actions">'
|
|
574
|
+
+ '<span class="transfer-size">' + formatSize(t.size) + '</span>';
|
|
575
|
+
if (t.status === 'completed' && t.direction === 'receive' && t.savePath) {
|
|
576
|
+
extra += '<button class="btn-open-folder" onclick="openFolder(\'' + esc(t.savePath.replace(/\\/g, '\\\\')) + '\')">Open Folder</button>';
|
|
577
|
+
}
|
|
578
|
+
extra += '</div>';
|
|
439
579
|
}
|
|
440
580
|
|
|
441
581
|
return '<div class="transfer">'
|
package/server.js
CHANGED
|
@@ -31,16 +31,40 @@ const XOR_KEY = Buffer.from('LAN-XFER-KEY-2024');
|
|
|
31
31
|
const PEER_TIMEOUT = 10_000; // ms
|
|
32
32
|
const BROADCAST_INTERVAL = 3_000; // ms
|
|
33
33
|
|
|
34
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
35
|
+
// Persistent config (device name)
|
|
36
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
37
|
+
const CONFIG_PATH = path.join(os.homedir(), '.lantransfer.json');
|
|
38
|
+
|
|
39
|
+
function loadConfig() {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
42
|
+
} catch { return {}; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function saveConfig(cfg) {
|
|
46
|
+
try {
|
|
47
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
|
|
48
|
+
console.log(` [config] Saved to ${CONFIG_PATH}`);
|
|
49
|
+
} catch (e) {
|
|
50
|
+
console.error(` [config] Failed to save: ${e.message}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const config = loadConfig();
|
|
55
|
+
|
|
34
56
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
35
57
|
// Application state
|
|
36
58
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
37
59
|
let isDiscoverable = true;
|
|
38
|
-
|
|
39
|
-
let
|
|
60
|
+
let deviceName = config.deviceName || os.hostname();
|
|
61
|
+
let downloadDir = config.downloadDir || path.join(os.homedir(), 'Downloads');
|
|
62
|
+
const sharedFiles = []; // [ { id, path, name, size } ]
|
|
40
63
|
const peers = new Map(); // id → { device_name, ip, tcp_port, last_seen }
|
|
41
64
|
const transfers = []; // [ TransferEntry ]
|
|
42
65
|
const pendingRequests = new Map(); // id → { filename, size, senderName, resolve }
|
|
43
66
|
let idCounter = 0;
|
|
67
|
+
let browseInProgress = false;
|
|
44
68
|
|
|
45
69
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
46
70
|
// SSE (Server-Sent Events) clients
|
|
@@ -76,18 +100,19 @@ function serializeState() {
|
|
|
76
100
|
return JSON.stringify({
|
|
77
101
|
isDiscoverable,
|
|
78
102
|
deviceName,
|
|
103
|
+
downloadDir,
|
|
79
104
|
localIP: getLocalIP(),
|
|
80
105
|
peers: [...peers.entries()].map(([id, p]) => ({
|
|
81
106
|
id, name: p.device_name, ip: p.ip,
|
|
82
107
|
})),
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
: null,
|
|
108
|
+
sharedFiles: sharedFiles.map(f => ({ id: f.id, name: f.name, size: f.size })),
|
|
109
|
+
selectedFileId: sharedFiles.length > 0 ? sharedFiles[sharedFiles.length - 1].id : null,
|
|
86
110
|
transfers: transfers.map(t => ({
|
|
87
111
|
id: t.id, filename: t.filename, size: t.size,
|
|
88
112
|
direction: t.direction, peerName: t.peerName,
|
|
89
113
|
status: t.status, progress: t.progress,
|
|
90
114
|
speed: t.speed, error: t.error,
|
|
115
|
+
savePath: t.savePath || null,
|
|
91
116
|
})),
|
|
92
117
|
incomingRequests: [...pendingRequests.entries()].map(([id, r]) => ({
|
|
93
118
|
id, filename: r.filename, size: r.size, senderName: r.senderName,
|
|
@@ -167,6 +192,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
167
192
|
|
|
168
193
|
// ── Ping endpoint for HTTP-based discovery ──
|
|
169
194
|
if (req.method === 'GET' && req.url === '/api/ping') {
|
|
195
|
+
if (!isDiscoverable) {
|
|
196
|
+
res.writeHead(403);
|
|
197
|
+
res.end('Hidden');
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
170
200
|
res.writeHead(200, {
|
|
171
201
|
'Content-Type': 'application/json',
|
|
172
202
|
'Access-Control-Allow-Origin': '*',
|
|
@@ -200,20 +230,64 @@ const server = http.createServer(async (req, res) => {
|
|
|
200
230
|
|
|
201
231
|
if (req.url === '/api/toggle') {
|
|
202
232
|
isDiscoverable = !isDiscoverable;
|
|
233
|
+
console.log(` [toggle] Discoverable: ${isDiscoverable}`);
|
|
203
234
|
}
|
|
204
235
|
else if (req.url === '/api/browse') {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
236
|
+
if (browseInProgress) {
|
|
237
|
+
console.log(' [browse] Dialog already open, ignoring');
|
|
238
|
+
} else {
|
|
239
|
+
browseInProgress = true;
|
|
240
|
+
console.log(' [browse] Opening file dialog...');
|
|
241
|
+
const fp = await openFileDialog();
|
|
242
|
+
browseInProgress = false;
|
|
243
|
+
if (fp) {
|
|
244
|
+
try {
|
|
245
|
+
const s = fs.statSync(fp);
|
|
246
|
+
const file = { id: String(idCounter++), path: fp, name: path.basename(fp), size: s.size };
|
|
247
|
+
sharedFiles.push(file);
|
|
248
|
+
console.log(` [browse] Added: ${file.name} (${formatSize(file.size)})`);
|
|
249
|
+
} catch (e) {
|
|
250
|
+
console.error(` [browse] Error reading file: ${e.message}`);
|
|
251
|
+
}
|
|
252
|
+
} else {
|
|
253
|
+
console.log(' [browse] Cancelled');
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
else if (req.url === '/api/remove-file') {
|
|
258
|
+
const idx = sharedFiles.findIndex(f => f.id === data.fileId);
|
|
259
|
+
if (idx !== -1) {
|
|
260
|
+
console.log(` [files] Removed: ${sharedFiles[idx].name}`);
|
|
261
|
+
sharedFiles.splice(idx, 1);
|
|
211
262
|
}
|
|
212
263
|
}
|
|
213
264
|
else if (req.url === '/api/send') {
|
|
214
265
|
const peer = peers.get(data.peerId);
|
|
215
|
-
|
|
216
|
-
|
|
266
|
+
const file = sharedFiles.find(f => f.id === data.fileId);
|
|
267
|
+
if (peer && file) {
|
|
268
|
+
console.log(` [send] ${file.name} -> ${peer.device_name} (${peer.ip})`);
|
|
269
|
+
sendFile(peer, { ...file });
|
|
270
|
+
} else {
|
|
271
|
+
console.log(` [send] Failed — peer: ${!!peer}, file: ${!!file}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
else if (req.url === '/api/rename') {
|
|
275
|
+
const newName = (data.name || '').trim();
|
|
276
|
+
if (newName && newName.length <= 50) {
|
|
277
|
+
deviceName = newName;
|
|
278
|
+
config.deviceName = newName;
|
|
279
|
+
saveConfig(config);
|
|
280
|
+
console.log(` [rename] Device name set to: ${deviceName}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
else if (req.url === '/api/set-download-dir') {
|
|
284
|
+
// Open native folder picker
|
|
285
|
+
const dir = await openFolderDialog();
|
|
286
|
+
if (dir) {
|
|
287
|
+
downloadDir = dir;
|
|
288
|
+
config.downloadDir = dir;
|
|
289
|
+
saveConfig(config);
|
|
290
|
+
console.log(` [config] Download dir set to: ${downloadDir}`);
|
|
217
291
|
}
|
|
218
292
|
}
|
|
219
293
|
else if (req.url === '/api/respond') {
|
|
@@ -221,6 +295,17 @@ const server = http.createServer(async (req, res) => {
|
|
|
221
295
|
if (pending) {
|
|
222
296
|
pending.resolve(!!data.accepted);
|
|
223
297
|
pendingRequests.delete(data.requestId);
|
|
298
|
+
console.log(` [respond] ${data.accepted ? 'Accepted' : 'Rejected'} transfer ${data.requestId}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
else if (req.url === '/api/open-folder') {
|
|
302
|
+
const filePath = data.path;
|
|
303
|
+
if (filePath && fs.existsSync(filePath)) {
|
|
304
|
+
const dir = path.dirname(filePath);
|
|
305
|
+
if (process.platform === 'win32') exec(`explorer /select,"${filePath}"`);
|
|
306
|
+
else if (process.platform === 'darwin') exec(`open -R "${filePath}"`);
|
|
307
|
+
else exec(`xdg-open "${dir}"`);
|
|
308
|
+
console.log(` [open] ${filePath}`);
|
|
224
309
|
}
|
|
225
310
|
}
|
|
226
311
|
else if (req.url === '/api/shutdown') {
|
|
@@ -260,6 +345,22 @@ function openFileDialog() {
|
|
|
260
345
|
});
|
|
261
346
|
}
|
|
262
347
|
|
|
348
|
+
function openFolderDialog() {
|
|
349
|
+
return new Promise(resolve => {
|
|
350
|
+
let cmd;
|
|
351
|
+
if (process.platform === 'win32') {
|
|
352
|
+
cmd = 'powershell -sta -command "Add-Type -AssemblyName System.Windows.Forms; $f = New-Object System.Windows.Forms.FolderBrowserDialog; $f.Description = \'Select download folder\'; if($f.ShowDialog() -eq \'OK\'){Write-Output $f.SelectedPath}"';
|
|
353
|
+
} else if (process.platform === 'darwin') {
|
|
354
|
+
cmd = 'osascript -e \'POSIX path of (choose folder with prompt "Select download folder")\'';
|
|
355
|
+
} else {
|
|
356
|
+
cmd = 'zenity --file-selection --directory --title="Select download folder" 2>/dev/null || kdialog --getexistingdirectory . 2>/dev/null';
|
|
357
|
+
}
|
|
358
|
+
exec(cmd, { encoding: 'utf-8', windowsHide: true, timeout: 120_000 }, (err, stdout) => {
|
|
359
|
+
resolve(err ? null : (stdout.trim() || null));
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
263
364
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
264
365
|
// UDP discovery
|
|
265
366
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
@@ -267,7 +368,6 @@ const udp = dgram.createSocket({ type: 'udp4', reuseAddr: true });
|
|
|
267
368
|
|
|
268
369
|
udp.on('listening', () => {
|
|
269
370
|
udp.setBroadcast(true);
|
|
270
|
-
console.log(` Discovery UDP :${DISCOVERY_PORT}`);
|
|
271
371
|
});
|
|
272
372
|
|
|
273
373
|
udp.on('message', (msg, rinfo) => {
|
|
@@ -325,9 +425,7 @@ tcpServer.on('error', err => {
|
|
|
325
425
|
console.error(' Is another instance running? (port conflict on TCP ' + TRANSFER_PORT + ')');
|
|
326
426
|
});
|
|
327
427
|
|
|
328
|
-
tcpServer.listen(TRANSFER_PORT
|
|
329
|
-
console.log(` Transfer TCP :${TRANSFER_PORT}`);
|
|
330
|
-
});
|
|
428
|
+
tcpServer.listen(TRANSFER_PORT);
|
|
331
429
|
|
|
332
430
|
async function handleIncoming(socket) {
|
|
333
431
|
// Read header length (8 bytes, little-endian u64)
|
|
@@ -371,8 +469,8 @@ async function handleIncoming(socket) {
|
|
|
371
469
|
transfers.push(t);
|
|
372
470
|
broadcast();
|
|
373
471
|
|
|
374
|
-
// Unique save path in
|
|
375
|
-
const dl =
|
|
472
|
+
// Unique save path in download directory
|
|
473
|
+
const dl = downloadDir;
|
|
376
474
|
if (!fs.existsSync(dl)) fs.mkdirSync(dl, { recursive: true });
|
|
377
475
|
let savePath = path.join(dl, filename);
|
|
378
476
|
let counter = 1;
|
|
@@ -382,6 +480,8 @@ async function handleIncoming(socket) {
|
|
|
382
480
|
savePath = path.join(dl, `${base} (${counter++})${ext}`);
|
|
383
481
|
}
|
|
384
482
|
|
|
483
|
+
t.savePath = savePath;
|
|
484
|
+
|
|
385
485
|
const ws = fs.createWriteStream(savePath);
|
|
386
486
|
let received = 0;
|
|
387
487
|
let lastBc = 0;
|
|
@@ -612,6 +712,9 @@ server.listen(HTTP_PORT, () => {
|
|
|
612
712
|
console.log(' ⇄ LAN File Transfer');
|
|
613
713
|
console.log(` Device: ${deviceName}`);
|
|
614
714
|
console.log(` Local IP: ${ip}`);
|
|
715
|
+
console.log(` Discovery HTTP scan (no firewall needed)`);
|
|
716
|
+
console.log(` Discovery UDP :${DISCOVERY_PORT} (fallback)`);
|
|
717
|
+
console.log(` Transfer TCP :${TRANSFER_PORT}`);
|
|
615
718
|
console.log(` UI: http://localhost:${HTTP_PORT}`);
|
|
616
719
|
console.log('');
|
|
617
720
|
|