livedesk 0.1.445 → 0.1.446
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/bin/livedesk.js +86 -6
- package/bootstrap/client-process-identity.js +20 -6
- package/bootstrap/runtime-lock.js +145 -33
- package/client/bin/livedesk-client.js +295 -100
- package/client/package.json +5 -5
- package/client/src/runtime/agent-process-lifecycle.js +81 -18
- package/hub/package.json +1 -1
- package/hub/src/remote-hub.js +284 -88
- package/hub/src/server.js +173 -28
- package/hub/src/transport/relay-hub-control.js +1004 -0
- package/hub/src/transport/udp-rendezvous.js +2 -0
- package/package.json +6 -6
- package/web/dist/assets/index-5opXQY8X.js +25 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-D3VhnrP6.js +0 -25
package/bin/livedesk.js
CHANGED
|
@@ -694,10 +694,11 @@ async function getProcessDetails(pid) {
|
|
|
694
694
|
}
|
|
695
695
|
}
|
|
696
696
|
|
|
697
|
-
const [nameResult, commandResult, groupResult, startResult] = await Promise.all([
|
|
697
|
+
const [nameResult, commandResult, groupResult, parentResult, startResult] = await Promise.all([
|
|
698
698
|
runQuiet('ps', ['-p', String(processId), '-o', 'comm=']),
|
|
699
699
|
runQuiet('ps', ['-p', String(processId), '-o', 'args=']),
|
|
700
700
|
runQuiet('ps', ['-p', String(processId), '-o', 'pgid=']),
|
|
701
|
+
runQuiet('ps', ['-p', String(processId), '-o', 'ppid=']),
|
|
701
702
|
runQuiet('ps', ['-p', String(processId), '-o', 'lstart='])
|
|
702
703
|
]);
|
|
703
704
|
if (!nameResult.stdout && !commandResult.stdout) return null;
|
|
@@ -711,7 +712,7 @@ async function getProcessDetails(pid) {
|
|
|
711
712
|
}
|
|
712
713
|
return {
|
|
713
714
|
processId,
|
|
714
|
-
parentProcessId: 0,
|
|
715
|
+
parentProcessId: Number(parentResult.stdout || 0),
|
|
715
716
|
processName: nameResult.stdout,
|
|
716
717
|
commandLine: commandResult.stdout,
|
|
717
718
|
executablePath,
|
|
@@ -720,6 +721,64 @@ async function getProcessDetails(pid) {
|
|
|
720
721
|
};
|
|
721
722
|
}
|
|
722
723
|
|
|
724
|
+
async function hasKnownLiveDeskClientAgentDescendant(rootPid) {
|
|
725
|
+
const processId = Number(rootPid);
|
|
726
|
+
if (!Number.isInteger(processId) || processId <= 1) return false;
|
|
727
|
+
let pairs = [];
|
|
728
|
+
if (os.platform() === 'win32') {
|
|
729
|
+
const script = '$ErrorActionPreference = "Stop"; @(Get-CimInstance Win32_Process | ForEach-Object { [pscustomobject]@{ ProcessId = [int]$_.ProcessId; ParentProcessId = [int]$_.ParentProcessId } }) | ConvertTo-Json -Compress';
|
|
730
|
+
const result = await runQuiet(
|
|
731
|
+
'powershell.exe',
|
|
732
|
+
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
|
|
733
|
+
{ timeout: 10000, killSignal: 'SIGKILL' }
|
|
734
|
+
);
|
|
735
|
+
if (!result.ok || !result.stdout) return false;
|
|
736
|
+
try {
|
|
737
|
+
const parsed = JSON.parse(result.stdout);
|
|
738
|
+
pairs = (Array.isArray(parsed) ? parsed : [parsed]).map(item => ({
|
|
739
|
+
pid: Number(item?.ProcessId || 0),
|
|
740
|
+
parentPid: Number(item?.ParentProcessId || 0)
|
|
741
|
+
}));
|
|
742
|
+
} catch {
|
|
743
|
+
return false;
|
|
744
|
+
}
|
|
745
|
+
} else {
|
|
746
|
+
const result = await runQuiet(
|
|
747
|
+
'ps',
|
|
748
|
+
['-axo', 'pid=,ppid='],
|
|
749
|
+
{ timeout: 10000, killSignal: 'SIGKILL' }
|
|
750
|
+
);
|
|
751
|
+
if (!result.ok || !result.stdout) return false;
|
|
752
|
+
pairs = result.stdout.split(/\r?\n/).flatMap(line => {
|
|
753
|
+
const match = line.match(/^\s*(\d+)\s+(\d+)\s*$/);
|
|
754
|
+
return match ? [{ pid: Number(match[1]), parentPid: Number(match[2]) }] : [];
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const childrenByParent = new Map();
|
|
759
|
+
for (const pair of pairs) {
|
|
760
|
+
if (!childrenByParent.has(pair.parentPid)) childrenByParent.set(pair.parentPid, []);
|
|
761
|
+
childrenByParent.get(pair.parentPid).push(pair.pid);
|
|
762
|
+
}
|
|
763
|
+
const queue = [...(childrenByParent.get(processId) || [])];
|
|
764
|
+
const seen = new Set();
|
|
765
|
+
while (queue.length > 0) {
|
|
766
|
+
const candidatePid = queue.shift();
|
|
767
|
+
if (!Number.isInteger(candidatePid) || candidatePid <= 1 || seen.has(candidatePid)) continue;
|
|
768
|
+
seen.add(candidatePid);
|
|
769
|
+
const details = await getProcessDetails(candidatePid);
|
|
770
|
+
if (details && isKnownLiveDeskClientAgentProcess(
|
|
771
|
+
details.processName,
|
|
772
|
+
details.commandLine,
|
|
773
|
+
details.executablePath
|
|
774
|
+
)) {
|
|
775
|
+
return true;
|
|
776
|
+
}
|
|
777
|
+
queue.push(...(childrenByParent.get(candidatePid) || []));
|
|
778
|
+
}
|
|
779
|
+
return false;
|
|
780
|
+
}
|
|
781
|
+
|
|
723
782
|
async function waitForProcessExit(pid, timeoutMs = EXISTING_RUNTIME_STOP_TIMEOUT_MS) {
|
|
724
783
|
const processId = Number(pid);
|
|
725
784
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -748,16 +807,26 @@ async function stopExistingRuntime(existing) {
|
|
|
748
807
|
return null;
|
|
749
808
|
}
|
|
750
809
|
const commandLineConfirmed = isKnownLiveDeskLauncherProcess(details.processName, details.commandLine);
|
|
751
|
-
const
|
|
810
|
+
const lockInstanceConfirmed = String(existing?.ownerToken || '').trim().length >= 16
|
|
811
|
+
&& String(existing?.ownerInstanceMarker || '').trim()
|
|
812
|
+
=== processInstanceMarker(details);
|
|
813
|
+
const legacyClientTreeConfirmed = !String(existing?.ownerToken || '').trim()
|
|
814
|
+
&& String(existing?.role || '').trim().toLowerCase() === 'client'
|
|
815
|
+
&& await hasKnownLiveDeskClientAgentDescendant(processId);
|
|
816
|
+
const runtimeProof = commandLineConfirmed || lockInstanceConfirmed || legacyClientTreeConfirmed
|
|
752
817
|
? null
|
|
753
818
|
: await probeExistingRuntimeIdentity(existing);
|
|
754
|
-
if (!commandLineConfirmed && !runtimeProof) {
|
|
819
|
+
if (!commandLineConfirmed && !lockInstanceConfirmed && !legacyClientTreeConfirmed && !runtimeProof) {
|
|
755
820
|
throw new Error(`Existing runtime pid ${processId} is not a confirmed LiveDesk launcher; it was preserved.`);
|
|
756
821
|
}
|
|
757
822
|
|
|
758
823
|
const identityProof = commandLineConfirmed
|
|
759
824
|
? 'command-line'
|
|
760
|
-
:
|
|
825
|
+
: lockInstanceConfirmed
|
|
826
|
+
? 'lock-instance'
|
|
827
|
+
: legacyClientTreeConfirmed
|
|
828
|
+
? 'legacy-lock-exact-client-descendant'
|
|
829
|
+
: `runtime-api:${runtimeProof.port}:${runtimeProof.appVersion || 'unknown'}`;
|
|
761
830
|
console.log(`[LiveDesk] Stopping existing ${existing?.role || 'runtime'} launcher pid ${processId} before starting a fresh runtime (identity=${identityProof}).`);
|
|
762
831
|
if (os.platform() === 'win32') {
|
|
763
832
|
await runQuiet('taskkill.exe', ['/PID', String(processId), '/T', '/F']);
|
|
@@ -1003,7 +1072,13 @@ async function stopStaleClientAgents() {
|
|
|
1003
1072
|
);
|
|
1004
1073
|
}
|
|
1005
1074
|
const candidatePids = [];
|
|
1006
|
-
const patterns = [
|
|
1075
|
+
const patterns = [
|
|
1076
|
+
'livedesk-client-fast',
|
|
1077
|
+
'livedesk-client-node.js',
|
|
1078
|
+
'livedesk-sck-h264',
|
|
1079
|
+
'livedesk-sck-audio',
|
|
1080
|
+
'livedesk-raw-capture-helper.mjs'
|
|
1081
|
+
];
|
|
1007
1082
|
for (const line of result.stdout.split(/\r?\n/)) {
|
|
1008
1083
|
if (!patterns.some(pattern => line.includes(pattern))) continue;
|
|
1009
1084
|
const match = line.match(/^\s*(\d+)\s+/);
|
|
@@ -2131,6 +2206,8 @@ async function main() {
|
|
|
2131
2206
|
if (topLevel.noSingleInstance || process.env.LIVEDESK_DISABLE_SINGLE_INSTANCE === '1') {
|
|
2132
2207
|
console.warn('[LiveDesk] Single-instance lock disabled for this run.');
|
|
2133
2208
|
}
|
|
2209
|
+
const launcherProcessDetails = await getProcessDetails(process.pid);
|
|
2210
|
+
const launcherInstanceMarker = processInstanceMarker(launcherProcessDetails);
|
|
2134
2211
|
const reportExistingRuntime = existing => {
|
|
2135
2212
|
const port = DEFAULT_MANAGER_HTTP_PORT;
|
|
2136
2213
|
openBrowser(`http://127.0.0.1:${port}`);
|
|
@@ -2141,6 +2218,7 @@ async function main() {
|
|
|
2141
2218
|
: acquireRuntimeLock({
|
|
2142
2219
|
stateDir: MANAGER_STATE_DIR,
|
|
2143
2220
|
role: resolvedRole.role,
|
|
2221
|
+
ownerInstanceMarker: launcherInstanceMarker,
|
|
2144
2222
|
openExisting: () => undefined
|
|
2145
2223
|
});
|
|
2146
2224
|
|
|
@@ -2151,6 +2229,7 @@ async function main() {
|
|
|
2151
2229
|
lock = acquireRuntimeLock({
|
|
2152
2230
|
stateDir: MANAGER_STATE_DIR,
|
|
2153
2231
|
role: resolvedRole.role,
|
|
2232
|
+
ownerInstanceMarker: launcherInstanceMarker,
|
|
2154
2233
|
openExisting: () => undefined
|
|
2155
2234
|
});
|
|
2156
2235
|
}
|
|
@@ -2162,6 +2241,7 @@ async function main() {
|
|
|
2162
2241
|
lock = acquireRuntimeLock({
|
|
2163
2242
|
stateDir: MANAGER_STATE_DIR,
|
|
2164
2243
|
role: resolvedRole.role,
|
|
2244
|
+
ownerInstanceMarker: launcherInstanceMarker,
|
|
2165
2245
|
openExisting: () => undefined
|
|
2166
2246
|
});
|
|
2167
2247
|
}
|
|
@@ -50,11 +50,25 @@ export function isKnownLiveDeskClientAgentProcess(processName, commandLine, exec
|
|
|
50
50
|
export function isKnownLiveDeskCaptureHelperProcess(processName, commandLine, executablePath = '') {
|
|
51
51
|
const normalizedName = String(processName || '').trim().replaceAll('\\', '/').split('/').pop() || '';
|
|
52
52
|
const normalizedCommandLine = String(commandLine || '').replaceAll('\\', '/');
|
|
53
|
-
const
|
|
54
|
-
const
|
|
53
|
+
const normalizedExecutablePath = normalizeExecutablePath(executablePath);
|
|
54
|
+
const argvExecutable = normalizeExecutablePath(firstCommandArgument(normalizedCommandLine));
|
|
55
|
+
const videoHelperPath = /(?:^|\/)\.livedesk\/helpers\/macos-sck-h264\/livedesk-sck-h264$/i;
|
|
56
|
+
const audioHelperPath = /(?:^|\/)\.livedesk\/helpers\/macos-sck-audio\/livedesk-sck-audio$/i;
|
|
57
|
+
const knownVideoCommand = /(?:^|\/)\.livedesk\/helpers\/macos-sck-h264\/livedesk-sck-h264(?:\s|["']|$)/i
|
|
58
|
+
.test(normalizedCommandLine);
|
|
59
|
+
const knownAudioCommand = /(?:^|\/)\.livedesk\/helpers\/macos-sck-audio\/livedesk-sck-audio(?:\s|["']|$)/i
|
|
55
60
|
.test(normalizedCommandLine);
|
|
56
|
-
const
|
|
57
|
-
||
|
|
58
|
-
|
|
59
|
-
||
|
|
61
|
+
const strictVideoExecutableProof = videoHelperPath.test(normalizedExecutablePath)
|
|
62
|
+
|| videoHelperPath.test(argvExecutable);
|
|
63
|
+
const strictAudioExecutableProof = audioHelperPath.test(normalizedExecutablePath)
|
|
64
|
+
|| audioHelperPath.test(argvExecutable);
|
|
65
|
+
const knownRawCaptureCommand = new RegExp(
|
|
66
|
+
`^(?:"[^"]*"|'[^']*'|\\S+)\\s+["']?\\S*${FAST_ROOT}/livedesk-raw-capture-helper\\.mjs(?:["']|\\s|$)`,
|
|
67
|
+
'i'
|
|
68
|
+
).test(normalizedCommandLine);
|
|
69
|
+
return (/^livedesk-sck-h264$/i.test(normalizedName) && knownVideoCommand)
|
|
70
|
+
|| (/^livedesk-sck-h2$/i.test(normalizedName) && strictVideoExecutableProof)
|
|
71
|
+
|| (/^livedesk-sck-audio$/i.test(normalizedName) && knownAudioCommand)
|
|
72
|
+
|| (/^livedesk-sck-aud$/i.test(normalizedName) && strictAudioExecutableProof)
|
|
73
|
+
|| (/^(?:node|node\.exe)$/i.test(normalizedName) && knownRawCaptureCommand);
|
|
60
74
|
}
|
|
@@ -1,15 +1,48 @@
|
|
|
1
|
-
import { existsSync,
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
1
|
+
import { existsSync, linkSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
4
5
|
|
|
5
|
-
function readLock(path) {
|
|
6
|
+
function readLock(path) {
|
|
6
7
|
try {
|
|
7
8
|
return JSON.parse(readFileSync(path, 'utf8'));
|
|
8
9
|
} catch {
|
|
9
10
|
return null;
|
|
10
11
|
}
|
|
11
|
-
}
|
|
12
|
-
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readLockText(path) {
|
|
15
|
+
try {
|
|
16
|
+
return readFileSync(path, 'utf8');
|
|
17
|
+
} catch {
|
|
18
|
+
return '';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function publishCompleteLock(targetPath, stagedPath, payload) {
|
|
23
|
+
writeFileSync(stagedPath, JSON.stringify(payload, null, 2), {
|
|
24
|
+
flag: 'wx',
|
|
25
|
+
mode: 0o600
|
|
26
|
+
});
|
|
27
|
+
try {
|
|
28
|
+
linkSync(stagedPath, targetPath);
|
|
29
|
+
} finally {
|
|
30
|
+
try { unlinkSync(stagedPath); } catch { /* staged inode was already removed */ }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function releaseOwnedLock(path, payload) {
|
|
35
|
+
try {
|
|
36
|
+
const current = readLock(path);
|
|
37
|
+
if (Number(current?.pid) === payload.pid
|
|
38
|
+
&& current?.ownerToken === payload.ownerToken) {
|
|
39
|
+
unlinkSync(path);
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// The exact owner already released the lock.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
13
46
|
function isProcessAlive(pid) {
|
|
14
47
|
if (!Number.isInteger(pid) || pid <= 1) {
|
|
15
48
|
return false;
|
|
@@ -26,33 +59,112 @@ export function getRuntimeLockPath(stateDir = join(os.homedir(), '.livedesk')) {
|
|
|
26
59
|
return join(stateDir, 'livedesk-runtime.lock');
|
|
27
60
|
}
|
|
28
61
|
|
|
29
|
-
export function acquireRuntimeLock({
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
62
|
+
export function acquireRuntimeLock({
|
|
63
|
+
stateDir,
|
|
64
|
+
role = '',
|
|
65
|
+
ownerInstanceMarker = '',
|
|
66
|
+
openExisting
|
|
67
|
+
} = {}) {
|
|
68
|
+
const path = getRuntimeLockPath(stateDir);
|
|
69
|
+
const payload = {
|
|
70
|
+
pid: process.pid,
|
|
71
|
+
role: String(role || '').trim(),
|
|
72
|
+
startedAt: new Date().toISOString(),
|
|
73
|
+
ownerToken: randomBytes(16).toString('hex'),
|
|
74
|
+
ownerInstanceMarker: String(ownerInstanceMarker || '').trim()
|
|
75
|
+
};
|
|
76
|
+
const stagedPath = `${path}.${process.pid}.${payload.ownerToken}.pending`;
|
|
77
|
+
const takeoverPath = `${path}.takeover`;
|
|
78
|
+
const takeoverStagedPath = `${takeoverPath}.${process.pid}.${payload.ownerToken}.pending`;
|
|
79
|
+
const existingTakeover = readLock(takeoverPath);
|
|
80
|
+
|
|
81
|
+
// Stale repair is synchronous and normally lasts only a few filesystem
|
|
82
|
+
// operations. Every contender that observes its marker must fail closed so
|
|
83
|
+
// nobody can acquire the main path in the unlink/link handoff window.
|
|
84
|
+
if (existingTakeover || existsSync(takeoverPath)) {
|
|
85
|
+
const existing = readLock(path);
|
|
86
|
+
if (typeof openExisting === 'function') {
|
|
87
|
+
openExisting(existing || existingTakeover);
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
acquired: false,
|
|
91
|
+
path,
|
|
92
|
+
existing: existing || existingTakeover,
|
|
93
|
+
error: new Error('LiveDesk runtime lock takeover is already in progress.')
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
// Publish a fully written inode with one atomic hard-link operation. With
|
|
99
|
+
// open(path, 'wx') followed by write(), another launcher could observe the
|
|
100
|
+
// temporary zero-byte file, classify it as stale, unlink it on macOS, and
|
|
101
|
+
// acquire a second lock while the first owner still held the old inode.
|
|
102
|
+
publishCompleteLock(path, stagedPath, payload);
|
|
103
|
+
let released = false;
|
|
104
|
+
const release = () => {
|
|
105
|
+
if (released) return;
|
|
106
|
+
released = true;
|
|
107
|
+
releaseOwnedLock(path, payload);
|
|
108
|
+
};
|
|
109
|
+
return { acquired: true, path, payload, release };
|
|
110
|
+
} catch (error) {
|
|
111
|
+
try { unlinkSync(stagedPath); } catch { /* staged inode was never created or was already removed */ }
|
|
112
|
+
const existingText = readLockText(path);
|
|
113
|
+
const existing = readLock(path);
|
|
114
|
+
if (existing && !isProcessAlive(Number(existing.pid))) {
|
|
115
|
+
const takeoverPayload = {
|
|
116
|
+
pid: process.pid,
|
|
117
|
+
role: 'runtime-lock-takeover',
|
|
118
|
+
startedAt: new Date().toISOString(),
|
|
119
|
+
ownerToken: randomBytes(16).toString('hex'),
|
|
120
|
+
ownerInstanceMarker: String(ownerInstanceMarker || '').trim()
|
|
121
|
+
};
|
|
122
|
+
try {
|
|
123
|
+
publishCompleteLock(takeoverPath, takeoverStagedPath, takeoverPayload);
|
|
124
|
+
} catch {
|
|
125
|
+
if (typeof openExisting === 'function') {
|
|
126
|
+
openExisting(existing);
|
|
127
|
+
}
|
|
128
|
+
return { acquired: false, path, existing, error };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const currentText = readLockText(path);
|
|
133
|
+
const current = readLock(path);
|
|
134
|
+
if (!current
|
|
135
|
+
|| currentText !== existingText
|
|
136
|
+
|| isProcessAlive(Number(current.pid))) {
|
|
137
|
+
if (typeof openExisting === 'function') {
|
|
138
|
+
openExisting(current);
|
|
139
|
+
}
|
|
140
|
+
return { acquired: false, path, existing: current, error };
|
|
141
|
+
}
|
|
142
|
+
unlinkSync(path);
|
|
143
|
+
try {
|
|
144
|
+
publishCompleteLock(path, stagedPath, payload);
|
|
145
|
+
} catch (takeoverError) {
|
|
146
|
+
const winner = readLock(path);
|
|
147
|
+
if (typeof openExisting === 'function') {
|
|
148
|
+
openExisting(winner);
|
|
149
|
+
}
|
|
150
|
+
return { acquired: false, path, existing: winner, error: takeoverError };
|
|
151
|
+
}
|
|
152
|
+
let released = false;
|
|
153
|
+
const release = () => {
|
|
154
|
+
if (released) return;
|
|
155
|
+
released = true;
|
|
156
|
+
releaseOwnedLock(path, payload);
|
|
157
|
+
};
|
|
158
|
+
return { acquired: true, path, payload, release };
|
|
159
|
+
} finally {
|
|
160
|
+
releaseOwnedLock(takeoverPath, takeoverPayload);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// An unreadable existing file is never evidence that the lock is stale.
|
|
164
|
+
// Preserve it and fail closed; the next invocation can repair it after its
|
|
165
|
+
// owner disappears, without ever admitting two launchers concurrently.
|
|
166
|
+
if (typeof openExisting === 'function') {
|
|
167
|
+
openExisting(existing);
|
|
56
168
|
}
|
|
57
169
|
return { acquired: false, path, existing, error };
|
|
58
170
|
}
|