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 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 runtimeProof = commandLineConfirmed
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
- : `runtime-api:${runtimeProof.port}:${runtimeProof.appVersion || 'unknown'}`;
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 = ['livedesk-client-fast', 'livedesk-client-node.js', 'livedesk-sck-h264'];
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 helperPathPattern = /(?:^|\/)\.livedesk\/helpers\/macos-sck-h264\/livedesk-sck-h264$/i;
54
- const knownCommand = /(?:^|\/)\.livedesk\/helpers\/macos-sck-h264\/livedesk-sck-h264(?:\s|["']|$)/i
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 strictExecutableProof = helperPathPattern.test(normalizeExecutablePath(executablePath))
57
- || helperPathPattern.test(normalizeExecutablePath(firstCommandArgument(normalizedCommandLine)));
58
- return (/^livedesk-sck-h264$/i.test(normalizedName) && knownCommand)
59
- || (/^livedesk-sck-h2$/i.test(normalizedName) && strictExecutableProof);
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, openSync, readFileSync, closeSync, unlinkSync, writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import os from 'node:os';
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({ stateDir, role = '', openExisting } = {}) {
30
- const path = getRuntimeLockPath(stateDir);
31
- const payload = {
32
- pid: process.pid,
33
- role: String(role || '').trim(),
34
- startedAt: new Date().toISOString()
35
- };
36
-
37
- try {
38
- const descriptor = openSync(path, 'wx');
39
- writeFileSync(descriptor, JSON.stringify(payload, null, 2));
40
- closeSync(descriptor);
41
- let released = false;
42
- const release = () => {
43
- if (released) return;
44
- released = true;
45
- try { unlinkSync(path); } catch { /* already released */ }
46
- };
47
- return { acquired: true, path, payload, release };
48
- } catch (error) {
49
- const existing = readLock(path);
50
- if (!existing || !isProcessAlive(Number(existing.pid))) {
51
- try { unlinkSync(path); } catch { /* another process may repair it */ }
52
- return acquireRuntimeLock({ stateDir, role, openExisting });
53
- }
54
- if (typeof openExisting === 'function') {
55
- openExisting(existing);
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
  }