@balaji003/lantransfer 1.0.4 → 1.0.6
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 +109 -20
- package/server.js +162 -58
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.6",
|
|
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,22 @@
|
|
|
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
|
+
|
|
138
161
|
/* ── Transfers ── */
|
|
139
162
|
.transfers-section { flex: 1; overflow-y: auto; padding: 16px 24px; }
|
|
140
163
|
.transfer {
|
|
@@ -216,7 +239,11 @@
|
|
|
216
239
|
<div class="header">
|
|
217
240
|
<div class="header-left">
|
|
218
241
|
<div class="logo">⇌ LAN Transfer</div>
|
|
219
|
-
<div class="device-info"
|
|
242
|
+
<div class="device-info">
|
|
243
|
+
<input class="device-name-edit" id="device-name" type="text" maxlength="50" title="Click to edit device name" />
|
|
244
|
+
<span style="color:var(--text-dim)">·</span>
|
|
245
|
+
<span id="local-ip"></span>
|
|
246
|
+
</div>
|
|
220
247
|
</div>
|
|
221
248
|
<div class="header-right">
|
|
222
249
|
<div class="toggle" id="toggle" onclick="api('toggle')">
|
|
@@ -243,9 +270,10 @@
|
|
|
243
270
|
<div class="send-panel">
|
|
244
271
|
<div class="send-title">Send File</div>
|
|
245
272
|
<div class="send-row">
|
|
246
|
-
<button class="btn btn-browse" onclick="
|
|
247
|
-
<span class="file-info" id="file-info">No
|
|
273
|
+
<button class="btn btn-browse" id="browse-btn" onclick="doBrowse()">Browse</button>
|
|
274
|
+
<span class="file-info" id="file-info">No files added</span>
|
|
248
275
|
</div>
|
|
276
|
+
<div class="file-list" id="file-list"></div>
|
|
249
277
|
<div class="send-row" style="margin-top: 12px;">
|
|
250
278
|
<button class="btn btn-send" id="send-btn" onclick="doSend()" disabled>Send</button>
|
|
251
279
|
</div>
|
|
@@ -273,6 +301,8 @@
|
|
|
273
301
|
// ──────────────────────────────────────────────────────────────────────────
|
|
274
302
|
let state = {};
|
|
275
303
|
let selectedPeerId = null;
|
|
304
|
+
let selectedFileId = null;
|
|
305
|
+
let browsing = false;
|
|
276
306
|
|
|
277
307
|
// ──────────────────────────────────────────────────────────────────────────
|
|
278
308
|
// SSE connection
|
|
@@ -282,6 +312,10 @@
|
|
|
282
312
|
evtSource = new EventSource('/events');
|
|
283
313
|
evtSource.onmessage = e => {
|
|
284
314
|
state = JSON.parse(e.data);
|
|
315
|
+
// Auto-select the latest file if none selected
|
|
316
|
+
if (!selectedFileId && state.sharedFiles && state.sharedFiles.length > 0) {
|
|
317
|
+
selectedFileId = state.sharedFiles[state.sharedFiles.length - 1].id;
|
|
318
|
+
}
|
|
285
319
|
render();
|
|
286
320
|
};
|
|
287
321
|
evtSource.onopen = () => {
|
|
@@ -297,7 +331,7 @@
|
|
|
297
331
|
// API
|
|
298
332
|
// ──────────────────────────────────────────────────────────────────────────
|
|
299
333
|
function api(endpoint, data) {
|
|
300
|
-
fetch('/api/' + endpoint, {
|
|
334
|
+
return fetch('/api/' + endpoint, {
|
|
301
335
|
method: 'POST',
|
|
302
336
|
headers: { 'Content-Type': 'application/json' },
|
|
303
337
|
body: JSON.stringify(data || {}),
|
|
@@ -309,9 +343,28 @@
|
|
|
309
343
|
render();
|
|
310
344
|
}
|
|
311
345
|
|
|
346
|
+
function selectFile(id) {
|
|
347
|
+
selectedFileId = id;
|
|
348
|
+
render();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function removeFile(id) {
|
|
352
|
+
api('remove-file', { fileId: id });
|
|
353
|
+
if (selectedFileId === id) selectedFileId = null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function doBrowse() {
|
|
357
|
+
if (browsing) return;
|
|
358
|
+
browsing = true;
|
|
359
|
+
document.getElementById('browse-btn').textContent = 'Opening...';
|
|
360
|
+
await api('browse');
|
|
361
|
+
browsing = false;
|
|
362
|
+
document.getElementById('browse-btn').textContent = 'Browse';
|
|
363
|
+
}
|
|
364
|
+
|
|
312
365
|
function doSend() {
|
|
313
|
-
if (selectedPeerId &&
|
|
314
|
-
api('send', { peerId: selectedPeerId });
|
|
366
|
+
if (selectedPeerId && selectedFileId) {
|
|
367
|
+
api('send', { peerId: selectedPeerId, fileId: selectedFileId });
|
|
315
368
|
}
|
|
316
369
|
}
|
|
317
370
|
|
|
@@ -326,13 +379,29 @@
|
|
|
326
379
|
}
|
|
327
380
|
}
|
|
328
381
|
|
|
382
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
383
|
+
// Device name editing
|
|
384
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
385
|
+
const nameInput = document.getElementById('device-name');
|
|
386
|
+
let nameLoaded = false;
|
|
387
|
+
nameInput.addEventListener('change', () => {
|
|
388
|
+
const val = nameInput.value.trim();
|
|
389
|
+
if (val) api('rename', { name: val });
|
|
390
|
+
});
|
|
391
|
+
nameInput.addEventListener('keydown', e => {
|
|
392
|
+
if (e.key === 'Enter') nameInput.blur();
|
|
393
|
+
});
|
|
394
|
+
|
|
329
395
|
// ──────────────────────────────────────────────────────────────────────────
|
|
330
396
|
// Render
|
|
331
397
|
// ──────────────────────────────────────────────────────────────────────────
|
|
332
398
|
function render() {
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
|
|
399
|
+
// Device name (only set once to avoid clobbering user edits)
|
|
400
|
+
if (!nameLoaded && state.deviceName) {
|
|
401
|
+
nameInput.value = state.deviceName;
|
|
402
|
+
nameLoaded = true;
|
|
403
|
+
}
|
|
404
|
+
document.getElementById('local-ip').textContent = state.localIP || '';
|
|
336
405
|
|
|
337
406
|
// Toggle
|
|
338
407
|
const toggle = document.getElementById('toggle');
|
|
@@ -350,7 +419,6 @@
|
|
|
350
419
|
if (!state.peers || state.peers.length === 0) {
|
|
351
420
|
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
421
|
} else {
|
|
353
|
-
// Validate selection still exists
|
|
354
422
|
if (selectedPeerId && !state.peers.some(p => p.id === selectedPeerId)) {
|
|
355
423
|
selectedPeerId = null;
|
|
356
424
|
}
|
|
@@ -362,26 +430,47 @@
|
|
|
362
430
|
).join('');
|
|
363
431
|
}
|
|
364
432
|
|
|
365
|
-
//
|
|
433
|
+
// Shared files
|
|
434
|
+
const files = state.sharedFiles || [];
|
|
366
435
|
const fileInfoEl = document.getElementById('file-info');
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
} else {
|
|
436
|
+
const fileListEl = document.getElementById('file-list');
|
|
437
|
+
|
|
438
|
+
if (files.length === 0) {
|
|
371
439
|
fileInfoEl.className = 'file-info';
|
|
372
|
-
fileInfoEl.textContent = 'No
|
|
440
|
+
fileInfoEl.textContent = 'No files added';
|
|
441
|
+
fileListEl.innerHTML = '';
|
|
442
|
+
selectedFileId = null;
|
|
443
|
+
} else {
|
|
444
|
+
fileInfoEl.className = 'file-info has-file';
|
|
445
|
+
fileInfoEl.textContent = files.length + ' file' + (files.length > 1 ? 's' : '') + ' ready to send';
|
|
446
|
+
// Validate selection
|
|
447
|
+
if (selectedFileId && !files.some(f => f.id === selectedFileId)) {
|
|
448
|
+
selectedFileId = files[files.length - 1].id;
|
|
449
|
+
}
|
|
450
|
+
fileListEl.innerHTML = files.map(f =>
|
|
451
|
+
'<div class="file-item">'
|
|
452
|
+
+ '<div class="file-item-left">'
|
|
453
|
+
+ '<input type="radio" name="sel-file" class="file-item-radio" '
|
|
454
|
+
+ (selectedFileId === f.id ? 'checked ' : '')
|
|
455
|
+
+ 'onchange="selectFile(\'' + esc(f.id) + '\')" />'
|
|
456
|
+
+ '<span class="file-item-name">' + esc(f.name) + '</span>'
|
|
457
|
+
+ '<span class="file-item-size">' + formatSize(f.size) + '</span>'
|
|
458
|
+
+ '</div>'
|
|
459
|
+
+ '<button class="file-item-remove" onclick="removeFile(\'' + esc(f.id) + '\')" title="Remove">×</button>'
|
|
460
|
+
+ '</div>'
|
|
461
|
+
).join('');
|
|
373
462
|
}
|
|
374
463
|
|
|
375
464
|
// Send button
|
|
376
465
|
const sendBtn = document.getElementById('send-btn');
|
|
377
|
-
const canSend = !!(selectedPeerId &&
|
|
466
|
+
const canSend = !!(selectedPeerId && selectedFileId && state.peers && state.peers.some(p => p.id === selectedPeerId));
|
|
378
467
|
sendBtn.disabled = !canSend;
|
|
379
468
|
if (canSend) {
|
|
380
469
|
const peer = state.peers.find(p => p.id === selectedPeerId);
|
|
381
470
|
sendBtn.textContent = 'Send to ' + (peer ? peer.name : 'peer');
|
|
382
|
-
} else if (selectedPeerId && !
|
|
471
|
+
} else if (selectedPeerId && !selectedFileId) {
|
|
383
472
|
sendBtn.textContent = 'Select a file first';
|
|
384
|
-
} else if (!selectedPeerId &&
|
|
473
|
+
} else if (!selectedPeerId && selectedFileId) {
|
|
385
474
|
sendBtn.textContent = 'Select a device first';
|
|
386
475
|
} else {
|
|
387
476
|
sendBtn.textContent = 'Send';
|
package/server.js
CHANGED
|
@@ -31,16 +31,39 @@ 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
|
-
|
|
60
|
+
let deviceName = config.deviceName || os.hostname();
|
|
61
|
+
const sharedFiles = []; // [ { id, path, name, size } ]
|
|
40
62
|
const peers = new Map(); // id → { device_name, ip, tcp_port, last_seen }
|
|
41
63
|
const transfers = []; // [ TransferEntry ]
|
|
42
64
|
const pendingRequests = new Map(); // id → { filename, size, senderName, resolve }
|
|
43
65
|
let idCounter = 0;
|
|
66
|
+
let browseInProgress = false;
|
|
44
67
|
|
|
45
68
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
46
69
|
// SSE (Server-Sent Events) clients
|
|
@@ -80,9 +103,8 @@ function serializeState() {
|
|
|
80
103
|
peers: [...peers.entries()].map(([id, p]) => ({
|
|
81
104
|
id, name: p.device_name, ip: p.ip,
|
|
82
105
|
})),
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
: null,
|
|
106
|
+
sharedFiles: sharedFiles.map(f => ({ id: f.id, name: f.name, size: f.size })),
|
|
107
|
+
selectedFileId: sharedFiles.length > 0 ? sharedFiles[sharedFiles.length - 1].id : null,
|
|
86
108
|
transfers: transfers.map(t => ({
|
|
87
109
|
id: t.id, filename: t.filename, size: t.size,
|
|
88
110
|
direction: t.direction, peerName: t.peerName,
|
|
@@ -165,6 +187,25 @@ const server = http.createServer(async (req, res) => {
|
|
|
165
187
|
return;
|
|
166
188
|
}
|
|
167
189
|
|
|
190
|
+
// ── Ping endpoint for HTTP-based discovery ──
|
|
191
|
+
if (req.method === 'GET' && req.url === '/api/ping') {
|
|
192
|
+
if (!isDiscoverable) {
|
|
193
|
+
res.writeHead(403);
|
|
194
|
+
res.end('Hidden');
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
res.writeHead(200, {
|
|
198
|
+
'Content-Type': 'application/json',
|
|
199
|
+
'Access-Control-Allow-Origin': '*',
|
|
200
|
+
});
|
|
201
|
+
res.end(JSON.stringify({
|
|
202
|
+
app: 'lantransfer',
|
|
203
|
+
device_name: deviceName,
|
|
204
|
+
tcp_port: TRANSFER_PORT,
|
|
205
|
+
}));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
168
209
|
// ── SSE stream ──
|
|
169
210
|
if (req.method === 'GET' && req.url === '/events') {
|
|
170
211
|
res.writeHead(200, {
|
|
@@ -186,20 +227,54 @@ const server = http.createServer(async (req, res) => {
|
|
|
186
227
|
|
|
187
228
|
if (req.url === '/api/toggle') {
|
|
188
229
|
isDiscoverable = !isDiscoverable;
|
|
230
|
+
console.log(` [toggle] Discoverable: ${isDiscoverable}`);
|
|
189
231
|
}
|
|
190
232
|
else if (req.url === '/api/browse') {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
233
|
+
if (browseInProgress) {
|
|
234
|
+
console.log(' [browse] Dialog already open, ignoring');
|
|
235
|
+
} else {
|
|
236
|
+
browseInProgress = true;
|
|
237
|
+
console.log(' [browse] Opening file dialog...');
|
|
238
|
+
const fp = await openFileDialog();
|
|
239
|
+
browseInProgress = false;
|
|
240
|
+
if (fp) {
|
|
241
|
+
try {
|
|
242
|
+
const s = fs.statSync(fp);
|
|
243
|
+
const file = { id: String(idCounter++), path: fp, name: path.basename(fp), size: s.size };
|
|
244
|
+
sharedFiles.push(file);
|
|
245
|
+
console.log(` [browse] Added: ${file.name} (${formatSize(file.size)})`);
|
|
246
|
+
} catch (e) {
|
|
247
|
+
console.error(` [browse] Error reading file: ${e.message}`);
|
|
248
|
+
}
|
|
249
|
+
} else {
|
|
250
|
+
console.log(' [browse] Cancelled');
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
else if (req.url === '/api/remove-file') {
|
|
255
|
+
const idx = sharedFiles.findIndex(f => f.id === data.fileId);
|
|
256
|
+
if (idx !== -1) {
|
|
257
|
+
console.log(` [files] Removed: ${sharedFiles[idx].name}`);
|
|
258
|
+
sharedFiles.splice(idx, 1);
|
|
197
259
|
}
|
|
198
260
|
}
|
|
199
261
|
else if (req.url === '/api/send') {
|
|
200
262
|
const peer = peers.get(data.peerId);
|
|
201
|
-
|
|
202
|
-
|
|
263
|
+
const file = sharedFiles.find(f => f.id === data.fileId);
|
|
264
|
+
if (peer && file) {
|
|
265
|
+
console.log(` [send] ${file.name} -> ${peer.device_name} (${peer.ip})`);
|
|
266
|
+
sendFile(peer, { ...file });
|
|
267
|
+
} else {
|
|
268
|
+
console.log(` [send] Failed — peer: ${!!peer}, file: ${!!file}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
else if (req.url === '/api/rename') {
|
|
272
|
+
const newName = (data.name || '').trim();
|
|
273
|
+
if (newName && newName.length <= 50) {
|
|
274
|
+
deviceName = newName;
|
|
275
|
+
config.deviceName = newName;
|
|
276
|
+
saveConfig(config);
|
|
277
|
+
console.log(` [rename] Device name set to: ${deviceName}`);
|
|
203
278
|
}
|
|
204
279
|
}
|
|
205
280
|
else if (req.url === '/api/respond') {
|
|
@@ -207,6 +282,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
207
282
|
if (pending) {
|
|
208
283
|
pending.resolve(!!data.accepted);
|
|
209
284
|
pendingRequests.delete(data.requestId);
|
|
285
|
+
console.log(` [respond] ${data.accepted ? 'Accepted' : 'Rejected'} transfer ${data.requestId}`);
|
|
210
286
|
}
|
|
211
287
|
}
|
|
212
288
|
else if (req.url === '/api/shutdown') {
|
|
@@ -253,7 +329,6 @@ const udp = dgram.createSocket({ type: 'udp4', reuseAddr: true });
|
|
|
253
329
|
|
|
254
330
|
udp.on('listening', () => {
|
|
255
331
|
udp.setBroadcast(true);
|
|
256
|
-
console.log(` Discovery UDP :${DISCOVERY_PORT}`);
|
|
257
332
|
});
|
|
258
333
|
|
|
259
334
|
udp.on('message', (msg, rinfo) => {
|
|
@@ -311,9 +386,7 @@ tcpServer.on('error', err => {
|
|
|
311
386
|
console.error(' Is another instance running? (port conflict on TCP ' + TRANSFER_PORT + ')');
|
|
312
387
|
});
|
|
313
388
|
|
|
314
|
-
tcpServer.listen(TRANSFER_PORT
|
|
315
|
-
console.log(` Transfer TCP :${TRANSFER_PORT}`);
|
|
316
|
-
});
|
|
389
|
+
tcpServer.listen(TRANSFER_PORT);
|
|
317
390
|
|
|
318
391
|
async function handleIncoming(socket) {
|
|
319
392
|
// Read header length (8 bytes, little-endian u64)
|
|
@@ -504,52 +577,83 @@ function formatSize(bytes) {
|
|
|
504
577
|
}
|
|
505
578
|
|
|
506
579
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
507
|
-
//
|
|
580
|
+
// HTTP-based discovery (no firewall rules needed — outbound TCP only)
|
|
508
581
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
509
|
-
function
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
582
|
+
function getSubnetIPs() {
|
|
583
|
+
const results = [];
|
|
584
|
+
for (const ifaces of Object.values(os.networkInterfaces())) {
|
|
585
|
+
for (const iface of ifaces) {
|
|
586
|
+
if (iface.family === 'IPv4' && !iface.internal && iface.netmask) {
|
|
587
|
+
const ipParts = iface.address.split('.').map(Number);
|
|
588
|
+
const maskParts = iface.netmask.split('.').map(Number);
|
|
589
|
+
// Only scan /24 or smaller subnets to keep it fast
|
|
590
|
+
if (maskParts[2] === 255) {
|
|
591
|
+
const base = ipParts.slice(0, 3).join('.');
|
|
592
|
+
for (let i = 1; i < 255; i++) {
|
|
593
|
+
const ip = `${base}.${i}`;
|
|
594
|
+
if (ip !== iface.address) results.push(ip);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
521
597
|
}
|
|
522
598
|
}
|
|
599
|
+
}
|
|
600
|
+
return results;
|
|
601
|
+
}
|
|
523
602
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
exec(psCmd, { windowsHide: false, timeout: 60_000 }, (err2) => {
|
|
539
|
-
if (err2) {
|
|
540
|
-
console.log(' !! Firewall permission denied. Discovery will NOT work.');
|
|
541
|
-
console.log(' !! To fix manually:');
|
|
542
|
-
console.log(' !! 1. Open Windows Security > Firewall & network protection');
|
|
543
|
-
console.log(' !! 2. Click "Allow an app through firewall"');
|
|
544
|
-
console.log(' !! 3. Add Node.js and allow Private + Public networks');
|
|
545
|
-
console.log('');
|
|
546
|
-
} else {
|
|
547
|
-
console.log(' Firewall: Rule added! Discovery should work now.');
|
|
548
|
-
}
|
|
603
|
+
function pingHost(ip) {
|
|
604
|
+
return new Promise(resolve => {
|
|
605
|
+
const req = http.get(`http://${ip}:${HTTP_PORT}/api/ping`, { timeout: 800 }, res => {
|
|
606
|
+
let body = '';
|
|
607
|
+
res.on('data', c => body += c);
|
|
608
|
+
res.on('end', () => {
|
|
609
|
+
try {
|
|
610
|
+
const d = JSON.parse(body);
|
|
611
|
+
if (d.app === 'lantransfer' && d.device_name !== deviceName) {
|
|
612
|
+
resolve({ ip, device_name: d.device_name, tcp_port: d.tcp_port });
|
|
613
|
+
} else resolve(null);
|
|
614
|
+
} catch { resolve(null); }
|
|
615
|
+
});
|
|
549
616
|
});
|
|
617
|
+
req.on('error', () => resolve(null));
|
|
618
|
+
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
550
619
|
});
|
|
551
620
|
}
|
|
552
621
|
|
|
622
|
+
let httpScanning = false;
|
|
623
|
+
async function httpDiscoveryScan() {
|
|
624
|
+
if (!isDiscoverable || httpScanning) return;
|
|
625
|
+
httpScanning = true;
|
|
626
|
+
try {
|
|
627
|
+
const ips = getSubnetIPs();
|
|
628
|
+
// Scan in batches of 50 to avoid fd exhaustion
|
|
629
|
+
for (let i = 0; i < ips.length; i += 50) {
|
|
630
|
+
const batch = ips.slice(i, i + 50);
|
|
631
|
+
const results = await Promise.all(batch.map(pingHost));
|
|
632
|
+
for (const r of results) {
|
|
633
|
+
if (!r) continue;
|
|
634
|
+
const id = `${r.ip}:${r.tcp_port}`;
|
|
635
|
+
const isNew = !peers.has(id);
|
|
636
|
+
peers.set(id, {
|
|
637
|
+
device_name: r.device_name,
|
|
638
|
+
ip: r.ip,
|
|
639
|
+
tcp_port: r.tcp_port,
|
|
640
|
+
last_seen: Date.now(),
|
|
641
|
+
});
|
|
642
|
+
if (isNew) {
|
|
643
|
+
console.log(` Found: ${r.device_name} (${r.ip})`);
|
|
644
|
+
broadcast();
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
} catch { /* scan error, ignore */ }
|
|
649
|
+
httpScanning = false;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Run HTTP discovery scan every 5 seconds
|
|
653
|
+
setInterval(httpDiscoveryScan, 5_000);
|
|
654
|
+
// Also run immediately on start (after a short delay for server to be ready)
|
|
655
|
+
setTimeout(httpDiscoveryScan, 1_000);
|
|
656
|
+
|
|
553
657
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
554
658
|
// Start
|
|
555
659
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
@@ -567,12 +671,12 @@ server.listen(HTTP_PORT, () => {
|
|
|
567
671
|
console.log(' ⇄ LAN File Transfer');
|
|
568
672
|
console.log(` Device: ${deviceName}`);
|
|
569
673
|
console.log(` Local IP: ${ip}`);
|
|
674
|
+
console.log(` Discovery HTTP scan (no firewall needed)`);
|
|
675
|
+
console.log(` Discovery UDP :${DISCOVERY_PORT} (fallback)`);
|
|
676
|
+
console.log(` Transfer TCP :${TRANSFER_PORT}`);
|
|
570
677
|
console.log(` UI: http://localhost:${HTTP_PORT}`);
|
|
571
678
|
console.log('');
|
|
572
679
|
|
|
573
|
-
// Check firewall on Windows
|
|
574
|
-
checkWindowsFirewall();
|
|
575
|
-
|
|
576
680
|
// Auto-open browser
|
|
577
681
|
const url = `http://localhost:${HTTP_PORT}`;
|
|
578
682
|
if (process.platform === 'win32') exec(`start "" "${url}"`);
|