@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 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.5",
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">&#8652; LAN Transfer</div>
219
- <div class="device-info" id="device-info"></div>
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)">&#183;</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="api('browse')">Browse</button>
247
- <span class="file-info" id="file-info">No file selected</span>
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 && state.selectedFile) {
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
- // Header
334
- const info = document.getElementById('device-info');
335
- info.textContent = (state.deviceName || '') + ' \u00B7 ' + (state.localIP || '');
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&hellip;<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
- // File info
477
+ // Shared files
478
+ const files = state.sharedFiles || [];
366
479
  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 {
480
+ const fileListEl = document.getElementById('file-list');
481
+
482
+ if (files.length === 0) {
371
483
  fileInfoEl.className = 'file-info';
372
- fileInfoEl.textContent = 'No file selected';
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">&times;</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 && state.selectedFile && state.peers && state.peers.some(p => p.id === 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 && !state.selectedFile) {
515
+ } else if (selectedPeerId && !selectedFileId) {
383
516
  sendBtn.textContent = 'Select a file first';
384
- } else if (!selectedPeerId && state.selectedFile) {
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
- const deviceName = os.hostname();
39
- let selectedFile = null; // { path, name, size }
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
- selectedFile: selectedFile
84
- ? { name: selectedFile.name, size: selectedFile.size }
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
- const fp = await openFileDialog();
206
- if (fp) {
207
- try {
208
- const s = fs.statSync(fp);
209
- selectedFile = { path: fp, name: path.basename(fp), size: s.size };
210
- } catch { selectedFile = null; }
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
- if (peer && selectedFile) {
216
- sendFile(peer, { ...selectedFile });
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 Downloads
375
- const dl = path.join(os.homedir(), 'Downloads');
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