@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 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.4",
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">&#8652; LAN Transfer</div>
219
- <div class="device-info" id="device-info"></div>
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)">&#183;</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="api('browse')">Browse</button>
247
- <span class="file-info" id="file-info">No file selected</span>
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 && state.selectedFile) {
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
- // Header
334
- const info = document.getElementById('device-info');
335
- info.textContent = (state.deviceName || '') + ' \u00B7 ' + (state.localIP || '');
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&hellip;<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
- // File info
433
+ // Shared files
434
+ const files = state.sharedFiles || [];
366
435
  const fileInfoEl = document.getElementById('file-info');
367
- if (state.selectedFile) {
368
- fileInfoEl.className = 'file-info has-file';
369
- fileInfoEl.textContent = state.selectedFile.name + ' \u00B7 ' + formatSize(state.selectedFile.size);
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 file selected';
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">&times;</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 && state.selectedFile && state.peers && state.peers.some(p => p.id === 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 && !state.selectedFile) {
471
+ } else if (selectedPeerId && !selectedFileId) {
383
472
  sendBtn.textContent = 'Select a file first';
384
- } else if (!selectedPeerId && state.selectedFile) {
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
- const deviceName = os.hostname();
39
- let selectedFile = null; // { path, name, size }
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
- selectedFile: selectedFile
84
- ? { name: selectedFile.name, size: selectedFile.size }
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
- const fp = await openFileDialog();
192
- if (fp) {
193
- try {
194
- const s = fs.statSync(fp);
195
- selectedFile = { path: fp, name: path.basename(fp), size: s.size };
196
- } catch { selectedFile = null; }
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
- if (peer && selectedFile) {
202
- sendFile(peer, { ...selectedFile });
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
- // Windows Firewall check
580
+ // HTTP-based discovery (no firewall rules needed — outbound TCP only)
508
581
  // ──────────────────────────────────────────────────────────────────────────────
509
- function checkWindowsFirewall() {
510
- if (process.platform !== 'win32') return;
511
-
512
- const ruleName = 'LAN Transfer (lantransfer)';
513
- const nodeExe = process.execPath;
514
-
515
- // Check if a firewall rule already exists
516
- exec(`netsh advfirewall firewall show rule name="${ruleName}"`, { windowsHide: true }, (err, stdout) => {
517
- if (!err && stdout.includes(ruleName)) {
518
- if (stdout.includes('Enabled:') && stdout.includes('Yes')) {
519
- console.log(' Firewall: Allowed');
520
- return;
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
- // No rule — prompt user via UAC elevation
525
- console.log('');
526
- console.log(' !! Firewall rule not found for LAN Transfer.');
527
- console.log(' !! Device discovery requires UDP/TCP through Windows Firewall.');
528
- console.log(' !! Requesting permission (UAC prompt)...');
529
- console.log('');
530
-
531
- const addCmd = [
532
- `netsh advfirewall firewall add rule name="${ruleName}" dir=in action=allow protocol=UDP localport=${DISCOVERY_PORT} program="${nodeExe}" enable=yes`,
533
- `netsh advfirewall firewall add rule name="${ruleName}" dir=in action=allow protocol=TCP localport=${TRANSFER_PORT} program="${nodeExe}" enable=yes`,
534
- ].join(' & ');
535
-
536
- const psCmd = `powershell -Command "Start-Process cmd -ArgumentList '/c ${addCmd.replace(/"/g, '\\"')}' -Verb RunAs -Wait"`;
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}"`);