livedesk 0.1.254 → 0.1.256

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 CHANGED
@@ -13,13 +13,15 @@ This starts the LiveDesk Hub, opens the local screen wall, and accepts clients.
13
13
  The Hub UI/API listens on `127.0.0.1` by default while the client endpoint
14
14
  continues to listen on the LAN.
15
15
 
16
- On startup, the launcher checks the Hub ports (`5179` and `5197`), stops every
17
- process listening on those configured ports, waits for the ports to be released,
18
- and then starts the new Hub. The client endpoint remains fixed at TCP `5197` by
19
- default so firewall rules, router forwarding, and direct clients keep a stable
20
- contract. If a process cannot be stopped or the port remains occupied, startup
21
- fails with the owning PID and command line. Pass `--no-clean` to disable the
22
- startup cleanup explicitly.
16
+ On startup, the launcher checks the Hub ports (`5179` and `5197`), identifies
17
+ stale LiveDesk Hub processes from their Node command line, stops each PID only
18
+ once, waits for the ports to be released and bindable, and then starts the new
19
+ Hub. Unrelated processes are preserved and reported with their PID, name, and
20
+ command line. The client endpoint remains fixed at TCP `5197` by default so
21
+ firewall rules, router forwarding, and direct clients keep a stable contract.
22
+ Pass `--no-clean` to disable the startup cleanup explicitly. If a child Hub
23
+ still loses a startup race with `EADDRINUSE`, the launcher performs one cleanup
24
+ and startup retry, then exits without looping.
23
25
 
24
26
  ## Client
25
27
 
package/bin/livedesk.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { createRequire } from 'node:module';
4
+ import net from 'node:net';
4
5
  import { dirname, join, resolve } from 'node:path';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import { execFile, spawn } from 'node:child_process';
@@ -14,6 +15,9 @@ const packageRoot = resolve(__dirname, '..');
14
15
  const DEFAULT_MANAGER_HTTP_PORT = 5179;
15
16
  const DEFAULT_REMOTE_HUB_PORT = 5197;
16
17
  const DEFAULT_MANAGER_URL = `http://127.0.0.1:${DEFAULT_MANAGER_HTTP_PORT}`;
18
+ const PORT_CLEANUP_TIMEOUT_MS = 3000;
19
+ const PORT_CLEANUP_POLL_MS = 100;
20
+ const HUB_STARTUP_RETRY_LIMIT = 1;
17
21
  const MANAGER_STATE_DIR = join(os.homedir(), '.livedesk');
18
22
  const MANAGER_STATE_PATH = join(MANAGER_STATE_DIR, 'manager.json');
19
23
 
@@ -212,6 +216,134 @@ function runQuiet(command, args) {
212
216
  });
213
217
  }
214
218
 
219
+ function waitMilliseconds(milliseconds) {
220
+ return new Promise(resolve => setTimeout(resolve, milliseconds));
221
+ }
222
+
223
+ function canBindPort(port, host = '0.0.0.0') {
224
+ return new Promise(resolve => {
225
+ const server = net.createServer();
226
+ let settled = false;
227
+ const finish = value => {
228
+ if (settled) return;
229
+ settled = true;
230
+ resolve(value);
231
+ };
232
+ server.once('error', () => finish(false));
233
+ server.listen(port, host, () => {
234
+ server.close(() => finish(true));
235
+ });
236
+ });
237
+ }
238
+
239
+ function isKnownLiveDeskProcess(processName, commandLine) {
240
+ const normalizedName = String(processName || '').trim();
241
+ const normalizedCommandLine = String(commandLine || '').replaceAll('\\', '/');
242
+ return /^(?:node|node\.exe)$/i.test(normalizedName)
243
+ && /livedesk/i.test(normalizedCommandLine)
244
+ && /hub\/src\/server\.js/i.test(normalizedCommandLine)
245
+ && /(?:node_modules\/livedesk|packages\/livedesk)\/hub\/src\/server\.js/i.test(normalizedCommandLine);
246
+ }
247
+
248
+ function isPortConflictOutput(output) {
249
+ return /\bEADDRINUSE\b|address already in use|requires client endpoint TCP \d+/i.test(String(output || ''));
250
+ }
251
+
252
+ function formatPortOwner(record) {
253
+ return `${record?.Pid || 'unknown'}:${record?.ProcessName || 'unknown'} cmd=${String(record?.CommandLine || 'unavailable').replace(/\s+/g, ' ').slice(0, 240)}`;
254
+ }
255
+
256
+ function assertPortCleanupReady(finalRecords, portStates) {
257
+ const blockedPorts = portStates.filter(port => port?.Ready !== true);
258
+ if (blockedPorts.length === 0) {
259
+ return;
260
+ }
261
+
262
+ const summary = blockedPorts.map(port => {
263
+ const owners = finalRecords
264
+ .filter(record => Number(record?.Port) === Number(port?.Port))
265
+ .map(formatPortOwner);
266
+ return `TCP ${port.Port}[available=${port.PortAvailable === true},bindable=${port.PortBindable === true},owners=${owners.length ? owners.join('; ') : 'owner not visible'}`;
267
+ }).join('], ');
268
+ throw new Error(`LiveDesk Hub startup cleanup could not release the configured port(s): ${summary}]. Unrelated processes are preserved; stop the owning process manually or choose an explicit --port/--remote-port value.`);
269
+ }
270
+
271
+ async function collectUnixPortRecords(ports) {
272
+ const records = [];
273
+ for (const port of ports) {
274
+ const lsof = await runQuiet('lsof', ['-nP', '-ti', `tcp:${port}`, '-sTCP:LISTEN']);
275
+ if (lsof.error?.code === 'ENOENT') {
276
+ throw new Error(`Could not inspect listening port TCP ${port}: ${lsof.stderr || lsof.error?.message || 'lsof failed'}`);
277
+ }
278
+ const pids = [...new Set(lsof.stdout.split(/\r?\n/).map(value => Number(value.trim())).filter(Number.isInteger))];
279
+ for (const pid of pids) {
280
+ const [nameResult, commandResult] = await Promise.all([
281
+ runQuiet('ps', ['-p', String(pid), '-o', 'comm=']),
282
+ runQuiet('ps', ['-p', String(pid), '-o', 'args='])
283
+ ]);
284
+ const processName = nameResult.stdout.trim();
285
+ const commandLine = commandResult.stdout.trim();
286
+ records.push({
287
+ Port: port,
288
+ Pid: pid,
289
+ ProcessName: processName,
290
+ CommandLine: commandLine,
291
+ KnownLiveDesk: isKnownLiveDeskProcess(processName, commandLine),
292
+ Action: 'preserved'
293
+ });
294
+ }
295
+ }
296
+ const unique = new Map();
297
+ for (const record of records) {
298
+ unique.set(`${record.Port}:${record.Pid}`, record);
299
+ }
300
+ return [...unique.values()];
301
+ }
302
+
303
+ async function stopUnixProcessesOnPorts(ports) {
304
+ const records = await collectUnixPortRecords(ports);
305
+ const killedPids = new Set();
306
+ for (const record of records) {
307
+ if (!record.KnownLiveDesk || killedPids.has(record.Pid)) continue;
308
+ killedPids.add(record.Pid);
309
+ await runQuiet('kill', ['-KILL', String(record.Pid)]);
310
+ for (const matching of records.filter(candidate => candidate.Pid === record.Pid)) {
311
+ matching.Action = 'stopped';
312
+ }
313
+ }
314
+
315
+ const deadline = Date.now() + PORT_CLEANUP_TIMEOUT_MS;
316
+ let finalRecords = [];
317
+ let portStates = [];
318
+ do {
319
+ finalRecords = await collectUnixPortRecords(ports);
320
+ portStates = [];
321
+ for (const port of ports) {
322
+ const busy = finalRecords.some(record => Number(record.Port) === Number(port));
323
+ const bindable = busy ? false : await canBindPort(port);
324
+ portStates.push({
325
+ Port: port,
326
+ PortAvailable: !busy,
327
+ PortBindable: bindable,
328
+ Ready: !busy && bindable
329
+ });
330
+ }
331
+ if (portStates.every(state => state.Ready) || Date.now() >= deadline) break;
332
+ await waitMilliseconds(PORT_CLEANUP_POLL_MS);
333
+ } while (true);
334
+
335
+ const stopped = records.filter(record => record.Action === 'stopped');
336
+ if (stopped.length > 0) {
337
+ const stoppedOwners = new Map();
338
+ for (const record of stopped) {
339
+ if (!stoppedOwners.has(String(record.Pid))) stoppedOwners.set(String(record.Pid), record);
340
+ }
341
+ const summary = [...stoppedOwners.values()].map(record => `${record.Pid}:${record.ProcessName || 'node'}`).join(', ');
342
+ console.log(`Restarting LiveDesk Hub. Stopped stale LiveDesk owner(s) once: ${summary}`);
343
+ }
344
+ assertPortCleanupReady(finalRecords, portStates);
345
+ }
346
+
215
347
  async function stopProcessesOnPorts(ports) {
216
348
  const uniquePorts = [...new Set(ports.map(port => normalizePort(port, 0)).filter(Boolean))];
217
349
  if (uniquePorts.length === 0) {
@@ -234,72 +366,93 @@ async function stopProcessesOnPorts(ports) {
234
366
  ' }',
235
367
  ' }',
236
368
  '}',
237
- '$records = foreach ($port in $ports) {',
369
+ 'function Get-PortRecords($port) {',
238
370
  ' $connections = @(Get-ListenConnections $port | Where-Object { $_.OwningProcess -and $_.OwningProcess -ne $currentNodePid -and $_.OwningProcess -ne $PID });',
239
371
  ' foreach ($connection in $connections) {',
240
372
  ' $owner = [int]$connection.OwningProcess;',
241
373
  ' $proc = Get-CimInstance Win32_Process -Filter "ProcessId = $owner" -ErrorAction SilentlyContinue;',
242
374
  ' $processName = if ($proc) { [string]$proc.Name } else { "" };',
243
375
  ' $commandLine = if ($proc) { [string]$proc.CommandLine } else { "" };',
244
- ' [pscustomobject]@{ Port = [int]$port; Pid = $owner; ProcessName = $processName; CommandLine = $commandLine; Action = "preserved"; PortAvailable = $false }',
376
+ ' $isNodeProcess = $processName -match "(?i)^node(?:\\.exe)?$";',
377
+ ' $hasLiveDeskToken = $commandLine -match "(?i)livedesk";',
378
+ ' $hasHubServerPath = $commandLine -match "(?i)hub[\\\\/]src[\\\\/]server\\.js";',
379
+ ' $hasPackagedHubPath = $commandLine -match "(?i)(?:node_modules|packages)[\\\\/]livedesk[\\\\/]hub[\\\\/]src[\\\\/]server\\.js";',
380
+ ' $knownLiveDesk = [bool]($isNodeProcess -and $hasLiveDeskToken -and $hasHubServerPath -and $hasPackagedHubPath);',
381
+ ' [pscustomobject]@{ Port = [int]$port; Pid = $owner; ProcessName = $processName; CommandLine = $commandLine; KnownLiveDesk = $knownLiveDesk; Action = "preserved"; KillAttempted = $false }',
382
+ ' }',
383
+ '}',
384
+ 'function Test-PortBindable($port) {',
385
+ ' $listener = $null;',
386
+ ' try {',
387
+ ' $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Any, [int]$port);',
388
+ ' $listener.Start();',
389
+ ' return $true;',
390
+ ' } catch {',
391
+ ' return $false;',
392
+ ' } finally {',
393
+ ' if ($listener) { $listener.Stop(); }',
245
394
  ' }',
246
395
  '}',
247
- '$records = @($records | Sort-Object Port,Pid -Unique);',
248
- 'foreach ($record in $records) {',
249
- ' Stop-Process -Id $record.Pid -Force -ErrorAction SilentlyContinue;',
250
- ' $record.Action = "stopped";',
396
+ '$records = @($ports | ForEach-Object { Get-PortRecords $_ } | Sort-Object Port,Pid -Unique);',
397
+ '$killableOwners = @($records | Where-Object { $_.KnownLiveDesk } | Group-Object Pid | ForEach-Object { @($_.Group)[0] });',
398
+ 'foreach ($owner in $killableOwners) {',
399
+ ' Stop-Process -Id $owner.Pid -Force -ErrorAction SilentlyContinue;',
400
+ ' foreach ($record in @($records | Where-Object { [int]$_.Pid -eq [int]$owner.Pid })) {',
401
+ ' $record.Action = "stopped";',
402
+ ' $record.KillAttempted = $true;',
403
+ ' }',
251
404
  '}',
252
- '$deadline = (Get-Date).ToUniversalTime().AddMilliseconds(3000);',
405
+ `$deadline = (Get-Date).ToUniversalTime().AddMilliseconds(${PORT_CLEANUP_TIMEOUT_MS});`,
406
+ '$finalRecords = @();',
407
+ '$portStates = @();',
253
408
  'do {',
254
- ' $busyConnections = @($ports | ForEach-Object { Get-ListenConnections $_ });',
255
- ' if ($busyConnections.Count -eq 0 -or (Get-Date).ToUniversalTime() -ge $deadline) { break }',
256
- ' Start-Sleep -Milliseconds 100;',
409
+ ' $finalRecords = @($ports | ForEach-Object { Get-PortRecords $_ });',
410
+ ' $busyPorts = @($finalRecords | Select-Object -ExpandProperty Port -Unique);',
411
+ ' $portStates = @($ports | ForEach-Object {',
412
+ ' $port = [int]$_;',
413
+ ' $busy = $busyPorts -contains $port;',
414
+ ' $bindable = if ($busy) { $false } else { Test-PortBindable $port };',
415
+ ' [pscustomobject]@{ Port = $port; PortAvailable = [bool](-not $busy); PortBindable = [bool]$bindable; Ready = [bool](-not $busy -and $bindable) }',
416
+ ' });',
417
+ ' if ((@($portStates | Where-Object { -not $_.Ready }).Count -eq 0) -or (Get-Date).ToUniversalTime() -ge $deadline) { break }',
418
+ ` Start-Sleep -Milliseconds ${PORT_CLEANUP_POLL_MS};`,
257
419
  '} while ($true);',
258
- '$busyPortNumbers = @($busyConnections | Select-Object -ExpandProperty LocalPort -Unique);',
259
- 'foreach ($record in $records) {',
260
- ' $record.PortAvailable = $busyPortNumbers -notcontains $record.Port;',
261
- '}',
262
- '$records | ConvertTo-Json -Compress'
420
+ '[pscustomobject]@{ Records = @($records); FinalRecords = @($finalRecords); Ports = @($portStates) } | ConvertTo-Json -Compress -Depth 8'
263
421
  ].join(' ');
264
422
  const result = await runQuiet('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script]);
265
423
  if (!result.ok && !result.stdout) {
266
424
  throw new Error(`Could not inspect existing LiveDesk Hub ports: ${result.stderr || result.error?.message || 'PowerShell failed'}`);
267
425
  }
268
- let records = [];
426
+ let payload = { Records: [], FinalRecords: [], Ports: [] };
269
427
  if (result.stdout) {
270
428
  try {
271
429
  const parsed = JSON.parse(result.stdout);
272
- records = Array.isArray(parsed) ? parsed : [parsed];
430
+ payload = Array.isArray(parsed)
431
+ ? { Records: parsed, FinalRecords: parsed, Ports: [] }
432
+ : parsed;
273
433
  } catch {
274
434
  throw new Error(`Could not parse existing LiveDesk Hub port state: ${result.stdout}`);
275
435
  }
276
436
  }
437
+ const records = Array.isArray(payload?.Records) ? payload.Records : [];
438
+ const finalRecords = Array.isArray(payload?.FinalRecords) ? payload.FinalRecords : [];
439
+ const portStates = Array.isArray(payload?.Ports) ? payload.Ports : [];
277
440
  const stopped = records.filter(record => record?.Action === 'stopped');
278
441
  if (stopped.length > 0) {
279
- const summary = stopped.map(record => `${record.Pid}:${record.ProcessName || 'node'}${record.Port ? `@${record.Port}` : ''}`).join(', ');
280
- console.log(`Restarting LiveDesk Hub. Stopped Hub port owner(s): ${summary}`);
281
- }
282
- const blocked = records.filter(record => record?.PortAvailable !== true);
283
- if (blocked.length > 0) {
284
- const summary = blocked.map(record => `${record.Pid}:${record.ProcessName || 'unknown'}@${record.Port}[port-free=${record.PortAvailable === true},cmd=${String(record.CommandLine || 'unavailable').replace(/\s+/g, ' ').slice(0, 240)}]`).join(', ');
285
- throw new Error(`LiveDesk Hub startup cleanup could not release port/process owner(s): ${summary}. Stop that process manually or choose an explicit --port/--remote-port value.`);
442
+ const stoppedOwners = new Map();
443
+ for (const record of stopped) {
444
+ if (!stoppedOwners.has(String(record.Pid))) {
445
+ stoppedOwners.set(String(record.Pid), record);
446
+ }
447
+ }
448
+ const summary = [...stoppedOwners.values()].map(record => `${record.Pid}:${record.ProcessName || 'node'}`).join(', ');
449
+ console.log(`Restarting LiveDesk Hub. Stopped stale LiveDesk owner(s) once: ${summary}`);
286
450
  }
451
+ assertPortCleanupReady(finalRecords, portStates);
287
452
  return;
288
453
  }
289
454
 
290
- const script = uniquePorts.map(port => [
291
- `pids="$(lsof -ti tcp:${port} -sTCP:LISTEN 2>/dev/null || true)"`,
292
- 'for pid in $pids; do',
293
- ' if [ "$pid" != "$$" ]; then',
294
- ' kill "$pid" 2>/dev/null || true',
295
- ' echo "$pid"',
296
- ' fi',
297
- 'done'
298
- ].join('; ')).join('; ');
299
- const result = await runQuiet('sh', ['-c', script]);
300
- if (result.stdout) {
301
- console.log(`Restarting LiveDesk Hub. Cleared port owner(s): ${result.stdout.replace(/\r?\n/g, ', ')}`);
302
- }
455
+ await stopUnixProcessesOnPorts(uniquePorts);
303
456
  }
304
457
 
305
458
  async function waitForManager(url = DEFAULT_MANAGER_URL, timeoutMs = 10000) {
@@ -358,23 +511,54 @@ async function runManager(args) {
358
511
  LIVEDESK_WEB_DIST: existsSync(resolve(packagedWebDist, 'index.html')) ? packagedWebDist : (process.env.LIVEDESK_WEB_DIST || '')
359
512
  };
360
513
 
361
- const child = spawn(process.execPath, [hubEntry, ...options.forwarded], {
362
- env,
363
- stdio: 'inherit',
364
- windowsHide: false
365
- });
514
+ let startupRetryCount = 0;
515
+ const startHubProcess = () => {
516
+ const stderrChunks = [];
517
+ const child = spawn(process.execPath, [hubEntry, ...options.forwarded], {
518
+ env,
519
+ stdio: ['inherit', 'inherit', 'pipe'],
520
+ windowsHide: false
521
+ });
366
522
 
367
- child.once('error', error => {
368
- console.error(`Failed to start LiveDesk Hub: ${error.message}`);
369
- process.exitCode = 1;
370
- });
371
- child.once('exit', (code, signal) => {
372
- if (signal) {
373
- process.kill(process.pid, signal);
374
- return;
375
- }
376
- process.exit(code ?? 0);
377
- });
523
+ child.stderr.on('data', chunk => {
524
+ const text = chunk.toString();
525
+ stderrChunks.push(text);
526
+ while (stderrChunks.join('').length > 16_000) {
527
+ stderrChunks.shift();
528
+ }
529
+ process.stderr.write(chunk);
530
+ });
531
+
532
+ child.once('error', error => {
533
+ console.error(`Failed to start LiveDesk Hub: ${error.message}`);
534
+ process.exitCode = 1;
535
+ });
536
+ child.once('exit', (code, signal) => {
537
+ const startupOutput = stderrChunks.join('');
538
+ if (!signal
539
+ && code !== 0
540
+ && options.cleanPortsOnStart
541
+ && startupRetryCount < HUB_STARTUP_RETRY_LIMIT
542
+ && isPortConflictOutput(startupOutput)) {
543
+ startupRetryCount += 1;
544
+ console.warn('[LiveDesk Hub] Startup port conflict detected. Cleaning Hub ports and retrying once.');
545
+ void stopProcessesOnPorts([httpPort, remotePort])
546
+ .then(() => startHubProcess())
547
+ .catch(error => {
548
+ console.error(error?.message || error);
549
+ process.exitCode = 1;
550
+ });
551
+ return;
552
+ }
553
+ if (signal) {
554
+ process.kill(process.pid, signal);
555
+ return;
556
+ }
557
+ process.exit(code ?? 0);
558
+ });
559
+ };
560
+
561
+ startHubProcess();
378
562
 
379
563
  if (options.openBrowserOnStart) {
380
564
  void waitForManager(openUrl).then(ok => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livedesk",
3
- "version": "0.1.254",
3
+ "version": "0.1.256",
4
4
  "description": "LiveDesk Hub and client launcher",
5
5
  "type": "module",
6
6
  "bin": {