livedesk 0.1.254 → 0.1.255
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 +9 -7
- package/bin/livedesk.js +237 -53
- package/package.json +1 -1
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`),
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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.ok && !lsof.stdout) {
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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 = @($
|
|
248
|
-
'
|
|
249
|
-
'
|
|
250
|
-
' $
|
|
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
|
-
|
|
405
|
+
`$deadline = (Get-Date).ToUniversalTime().AddMilliseconds(${PORT_CLEANUP_TIMEOUT_MS});`,
|
|
406
|
+
'$finalRecords = @();',
|
|
407
|
+
'$portStates = @();',
|
|
253
408
|
'do {',
|
|
254
|
-
' $
|
|
255
|
-
'
|
|
256
|
-
'
|
|
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
|
-
'$
|
|
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
|
|
426
|
+
let payload = { Records: [], FinalRecords: [], Ports: [] };
|
|
269
427
|
if (result.stdout) {
|
|
270
428
|
try {
|
|
271
429
|
const parsed = JSON.parse(result.stdout);
|
|
272
|
-
|
|
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
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
process.
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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 => {
|