@livedesk/client 0.1.201 → 0.1.203
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
|
@@ -62,9 +62,9 @@ System Settings path when access is missing; after granting access, restart the
|
|
|
62
62
|
client. When file transfer is enabled by the Hub, received files are saved
|
|
63
63
|
to `Desktop/LiveDeskFiles` unless the Hub sets another destination folder.
|
|
64
64
|
|
|
65
|
-
By default, the launcher uses the packaged C# RemoteFast engine when supported.
|
|
66
|
-
It falls back to the Node engine
|
|
67
|
-
|
|
65
|
+
By default, the launcher uses the packaged C# RemoteFast engine when supported.
|
|
66
|
+
It falls back to the Node engine only when a compatible RemoteFast runtime is
|
|
67
|
+
unavailable. Windows, macOS, and Linux all try RemoteFast first so
|
|
68
68
|
the Hub can request the Mode 3 hardware video path when it is available.
|
|
69
69
|
RemoteFast is shipped as a platform-specific self-contained .NET executable,
|
|
70
70
|
so client computers do not need a separate .NET installation.
|
|
@@ -1552,7 +1552,7 @@ function remotePolicyAllows(options, command) {
|
|
|
1552
1552
|
|
|
1553
1553
|
function buildClientUpdateBootstrapScript() {
|
|
1554
1554
|
return [
|
|
1555
|
-
'const { spawn } = require("node:child_process");',
|
|
1555
|
+
'const { execFile, spawn } = require("node:child_process");',
|
|
1556
1556
|
'const fs = require("node:fs");',
|
|
1557
1557
|
'const path = require("node:path");',
|
|
1558
1558
|
'const operationId = String(process.env.LIVEDESK_CLIENT_UPDATE_OPERATION_ID || "").trim();',
|
|
@@ -1569,15 +1569,29 @@ function buildClientUpdateBootstrapScript() {
|
|
|
1569
1569
|
'const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));',
|
|
1570
1570
|
'const isAlive = pid => { try { process.kill(pid, 0); return true; } catch (error) { return error?.code === "EPERM"; } };',
|
|
1571
1571
|
'const isGroupAlive = pid => { try { process.kill(-pid, 0); return true; } catch (error) { return error?.code === "EPERM"; } };',
|
|
1572
|
+
'const runWindowsPowerShell = script => new Promise((resolve, reject) => { execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { windowsHide: true, timeout: 15000, killSignal: "SIGKILL", maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => { if (error) { reject(new Error(String(stderr || error.message || "PowerShell failed").trim())); return; } resolve(String(stdout || "").trim()); }); });',
|
|
1573
|
+
'const queryWindowsProcessTable = async () => { const stdout = await runWindowsPowerShell(`$ErrorActionPreference = "Stop"; $records = @(Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,CreationDate | ForEach-Object { $ticks = if ($_.CreationDate) { [string]$_.CreationDate.ToUniversalTime().Ticks } else { "" }; [pscustomobject]@{ ProcessId = [int]$_.ProcessId; ParentProcessId = [int]$_.ParentProcessId; CreationUtcTicks = $ticks } }); [pscustomobject]@{ Records = $records } | ConvertTo-Json -Compress -Depth 4`); if (!stdout) throw new Error("Windows process snapshot returned no data"); const parsed = JSON.parse(stdout); const values = parsed?.Records == null ? [] : (Array.isArray(parsed.Records) ? parsed.Records : [parsed.Records]); return values.map(item => ({ pid: Number(item?.ProcessId || 0), parentPid: Number(item?.ParentProcessId || 0), startOrder: String(item?.CreationUtcTicks || "") })).filter(item => Number.isInteger(item.pid) && item.pid > 1 && /^\\d+$/.test(item.startOrder)); };',
|
|
1574
|
+
'const queryTrackedWindowsProcessTable = child => { if (child.livedeskWindowsSnapshotPromise) return child.livedeskWindowsSnapshotPromise; const pending = queryWindowsProcessTable(); child.livedeskWindowsSnapshotPromise = pending; const clear = () => { if (child.livedeskWindowsSnapshotPromise === pending) child.livedeskWindowsSnapshotPromise = null; }; pending.then(clear, clear); return pending; };',
|
|
1575
|
+
'const sameWindowsIdentity = (left, right) => Number(left?.pid || 0) === Number(right?.pid || 0) && String(left?.startOrder || "") === String(right?.startOrder || "");',
|
|
1576
|
+
'const captureWindowsIdentity = async child => { const processId = Number(child?.pid || 0); const deadline = Date.now() + 2500; let lastError = null; do { try { const snapshot = await queryTrackedWindowsProcessTable(child); const identity = snapshot.find(item => item.pid === processId) || null; if (identity) return identity; } catch (error) { lastError = error; } if (Date.now() < deadline) await sleep(50); } while (Date.now() < deadline); throw new Error(`Could not capture immutable Windows CreationDate for pid=${processId}: ${lastError?.message || "process not visible"}`); };',
|
|
1577
|
+
'const windowsTicksAtMilliseconds = value => BigInt(Math.trunc(Number(value) || Date.now())) * 10000n + 621355968000000000n;',
|
|
1578
|
+
'const setWindowsRootLifetimeEnd = (child, ticks) => { const candidate = BigInt(ticks); const previous = child.livedeskWindowsRootLifetimeEndTicks; if (!previous || candidate < BigInt(previous)) child.livedeskWindowsRootLifetimeEndTicks = String(candidate); return BigInt(child.livedeskWindowsRootLifetimeEndTicks); };',
|
|
1579
|
+
'const mergeRootAbsentWindowsTree = (child, rootPid, snapshot) => { const tracked = child.livedeskWindowsTrackedRecords || (child.livedeskWindowsTrackedRecords = new Map()); const currentByPid = new Map(snapshot.map(item => [item.pid, item])); const exactCurrentByPid = new Map(); for (const record of tracked.values()) { const current = currentByPid.get(record.pid); if (sameWindowsIdentity(current, record)) exactCurrentByPid.set(record.pid, record); } const lowerBound = windowsTicksAtMilliseconds(Number(child.livedeskWindowsSpawnedAtMs || Date.now()) - 2000); const upperBound = child.livedeskWindowsRootLifetimeEndTicks ? BigInt(child.livedeskWindowsRootLifetimeEndTicks) : null; let changed = true; while (changed) { changed = false; for (const record of snapshot) { const key = `${record.pid}:${record.startOrder}`; if (record.pid === rootPid || tracked.has(key)) continue; let recordStart; try { recordStart = BigInt(record.startOrder); if (recordStart < lowerBound || (upperBound && recordStart > upperBound)) continue; } catch { continue; } const parent = record.parentPid === rootPid ? { depth: 0 } : exactCurrentByPid.get(record.parentPid); if (!parent) continue; const descendant = { ...record, depth: Number(parent.depth || 0) + 1 }; tracked.set(key, descendant); exactCurrentByPid.set(descendant.pid, descendant); changed = true; } } return tracked; };',
|
|
1580
|
+
'const mergeExactWindowsTree = (child, root, snapshot) => { const tracked = child.livedeskWindowsTrackedRecords || (child.livedeskWindowsTrackedRecords = new Map()); const currentByPid = new Map(snapshot.map(item => [item.pid, item])); const currentRoot = currentByPid.get(root.pid) || null; const rootIsExact = sameWindowsIdentity(currentRoot, root); const rootWasReused = Boolean(currentRoot && !rootIsExact); if (rootWasReused) setWindowsRootLifetimeEnd(child, BigInt(currentRoot.startOrder) - 1n); else if (!currentRoot && child.livedeskWindowsRootExitedAtMs) setWindowsRootLifetimeEnd(child, windowsTicksAtMilliseconds(child.livedeskWindowsRootExitedAtMs)); const rootLifetimeClosed = Boolean(child.livedeskWindowsRootLifetimeEndTicks); if (rootIsExact) tracked.set(`${root.pid}:${root.startOrder}`, { ...root, depth: 0 }); const exactCurrentByPid = new Map(); if (rootIsExact) exactCurrentByPid.set(root.pid, { ...root, depth: 0 }); for (const record of tracked.values()) { const current = currentByPid.get(record.pid); if (sameWindowsIdentity(current, record)) exactCurrentByPid.set(record.pid, record); } let changed = true; while (changed) { changed = false; for (const record of snapshot) { const key = `${record.pid}:${record.startOrder}`; if (record.pid === root.pid || tracked.has(key)) continue; let recordStart; try { recordStart = BigInt(record.startOrder); if (recordStart < BigInt(root.startOrder)) continue; if (rootLifetimeClosed && recordStart > BigInt(child.livedeskWindowsRootLifetimeEndTicks)) continue; } catch { continue; } let parent = exactCurrentByPid.get(record.parentPid) || null; if (!parent && !currentRoot && rootLifetimeClosed && record.parentPid === root.pid) parent = { ...root, depth: 0 }; if (!parent) continue; const descendant = { ...record, depth: Number(parent.depth || 0) + 1 }; tracked.set(key, descendant); exactCurrentByPid.set(descendant.pid, descendant); changed = true; } } return tracked; };',
|
|
1581
|
+
'const refreshTrackedWindowsTree = async (child, root = child.livedeskWindowsIdentity || null) => { const snapshot = await queryTrackedWindowsProcessTable(child); if (root) mergeExactWindowsTree(child, root, snapshot); else mergeRootAbsentWindowsTree(child, Number(child.pid || 0), snapshot); return snapshot; };',
|
|
1582
|
+
'const stopExactWindowsRecords = async records => { if (!Array.isArray(records) || records.length === 0) return; const encoded = Buffer.from(JSON.stringify(records.map(record => ({ pid: record.pid, startOrder: record.startOrder }))), "utf8").toString("base64"); const script = `$ErrorActionPreference = "Stop"; $targetsJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String("${encoded}")); $targets = ConvertFrom-Json -InputObject $targetsJson; $failures = [System.Collections.Generic.List[string]]::new(); foreach ($item in $targets) { $targetPid = [int]$item.pid; $expectedStartTicks = [string]$item.startOrder; try { $target = Get-CimInstance Win32_Process -Filter "ProcessId = $targetPid" -Property ProcessId,CreationDate -ErrorAction SilentlyContinue | Select-Object -First 1; if (-not $target) { continue }; $targetStartTicks = if ($target.CreationDate) { [string]$target.CreationDate.ToUniversalTime().Ticks } else { "" }; if ($targetStartTicks -ne $expectedStartTicks) { continue }; Stop-Process -Id $targetPid -Force -ErrorAction Stop } catch { $failures.Add("pid=$targetPid $($_.Exception.Message)") } }; if ($failures.Count -gt 0) { [Console]::Error.WriteLine(($failures -join "; ")); exit 1 }`; await runWindowsPowerShell(script); };',
|
|
1583
|
+
'const trackWindowsChild = child => { if (process.platform !== "win32" || Number(child?.pid || 0) <= 1 || child.livedeskWindowsIdentityPromise) return; child.livedeskWindowsTrackedRecords = new Map(); child.livedeskWindowsSpawnedAtMs = Date.now(); child.livedeskWindowsRootExitedAtMs = null; child.livedeskWindowsTrackingStopped = false; child.once("exit", () => { if (!child.livedeskWindowsRootExitedAtMs) child.livedeskWindowsRootExitedAtMs = Date.now(); setWindowsRootLifetimeEnd(child, windowsTicksAtMilliseconds(child.livedeskWindowsRootExitedAtMs)); }); const refresh = () => { void refreshTrackedWindowsTree(child).then(() => { child.livedeskWindowsTrackingError = null; }).catch(error => { child.livedeskWindowsTrackingError = error; }); }; refresh(); child.livedeskWindowsTrackingTimer = setInterval(refresh, 250); child.livedeskWindowsTrackingTimer.unref?.(); child.livedeskWindowsIdentityPromise = (async () => { while (!child.livedeskWindowsTrackingStopped && !child.livedeskWindowsRootExitedAtMs) { try { const identity = await captureWindowsIdentity(child); child.livedeskWindowsIdentity = identity; refresh(); return identity; } catch (error) { child.livedeskWindowsIdentityError = error; if (!child.livedeskWindowsTrackingStopped && !child.livedeskWindowsRootExitedAtMs) await sleep(250); } } return null; })(); };',
|
|
1584
|
+
'const stopWindowsTracking = child => { child.livedeskWindowsTrackingStopped = true; if (child?.livedeskWindowsTrackingTimer) clearInterval(child.livedeskWindowsTrackingTimer); child.livedeskWindowsTrackingTimer = null; };',
|
|
1585
|
+
'const terminateExactWindowsTrackedTree = (child, root) => { if (child.livedeskWindowsDrainPromise) return child.livedeskWindowsDrainPromise; child.livedeskWindowsDrainPromise = (async () => { let attempt = 0; let emptyPasses = 0; for (;;) { attempt += 1; try { const snapshot = await refreshTrackedWindowsTree(child, root); const currentByPid = new Map(snapshot.map(item => [item.pid, item])); const remaining = [...child.livedeskWindowsTrackedRecords.values()].filter(target => sameWindowsIdentity(currentByPid.get(target.pid), target)).sort((left, right) => Number(right.depth || 0) - Number(left.depth || 0)); if (remaining.length === 0) { emptyPasses += 1; if (emptyPasses >= 2) { stopWindowsTracking(child); return; } } else { emptyPasses = 0; await stopExactWindowsRecords(remaining); } child.livedeskWindowsTrackingError = null; } catch (error) { emptyPasses = 0; child.livedeskWindowsTrackingError = error; if (attempt === 1 || attempt % 10 === 0) process.stderr.write(`LiveDesk update cleanup is retrying exact Windows process-tree drain: ${error?.message || error}\\n`); } await sleep(Math.min(1000, 100 + (attempt * 50))); } })(); return child.livedeskWindowsDrainPromise; };',
|
|
1572
1586
|
'const stateLockPath = statePath + ".lock"; const lockWaitBuffer = new Int32Array(new SharedArrayBuffer(4));',
|
|
1573
1587
|
'const waitForStateLock = ms => Atomics.wait(lockWaitBuffer, 0, 0, Math.max(1, ms));',
|
|
1574
1588
|
'const removeAbandonedStateLock = () => { let owner = null; try { owner = JSON.parse(fs.readFileSync(stateLockPath, "utf8")); } catch {} const ownerPid = Number(owner?.pid || 0); if (Number.isInteger(ownerPid) && ownerPid > 1) { if (isAlive(ownerPid)) return false; } else { try { if (Date.now() - fs.statSync(stateLockPath).mtimeMs < 5000) return false; } catch { return true; } } try { fs.rmSync(stateLockPath); return true; } catch { return false; } };',
|
|
1575
1589
|
'const withStateLock = callback => { fs.mkdirSync(path.dirname(stateLockPath), { recursive: true }); const deadline = Date.now() + 10000; let descriptor = null; let token = ""; while (descriptor === null) { try { descriptor = fs.openSync(stateLockPath, "wx", 0o600); token = process.pid + "-" + Date.now() + "-" + Math.random().toString(16).slice(2); fs.writeFileSync(descriptor, JSON.stringify({ pid: process.pid, token, acquiredAt: new Date().toISOString() }), "utf8"); } catch (error) { if (descriptor !== null) { try { fs.closeSync(descriptor); } catch {} descriptor = null; try { fs.rmSync(stateLockPath, { force: true }); } catch {} } if (error?.code !== "EEXIST") throw error; if (removeAbandonedStateLock()) continue; if (Date.now() >= deadline) throw new Error("Timed out waiting for the LiveDesk update state lock: " + stateLockPath); waitForStateLock(Math.min(25, Math.max(1, deadline - Date.now()))); } } try { return callback(); } finally { try { fs.closeSync(descriptor); } catch {} try { const owner = JSON.parse(fs.readFileSync(stateLockPath, "utf8")); if (owner?.token === token && Number(owner?.pid) === process.pid) fs.rmSync(stateLockPath, { force: true }); } catch {} } };',
|
|
1576
1590
|
'const writeFailure = (error, cancelRequested = false) => { try { withStateLock(() => { const previous = readState(); if (previous.operationId && previous.operationId !== operationId) return; if (previous.stage === "preflight-ready" || previous.stage === "waiting-for-shutdown" || previous.stage === "connected" || previous.stage === "restored") return; const now = new Date().toISOString(); const next = { ...previous, operationId, stage: "failed", targetProductVersion, restartVerified: false, cancelRequested: cancelRequested || previous.cancelRequested === true, error: String(error?.message || error).slice(0, 4000), failedAt: now, updatedAt: now }; const temporary = statePath + "." + process.pid + ".handoff.tmp"; fs.writeFileSync(temporary, JSON.stringify(next, null, 2), { encoding: "utf8", mode: 0o600 }); fs.renameSync(temporary, statePath); }); } catch {} process.stderr.write("LiveDesk update handoff failed: " + (error?.message || error) + "\\n"); process.exitCode = 1; };',
|
|
1577
1591
|
'const writeCancellation = error => withStateLock(() => { const previous = readState(); if (previous.operationId && previous.operationId !== operationId) return "superseded"; if (previous.operationId !== operationId) return false; if (previous.stage === "preflight-ready" || previous.stage === "waiting-for-shutdown") return "ready"; if (previous.stage === "failed") return previous.cancelRequested === true ? "cancelled" : "failed"; const now = new Date().toISOString(); const next = { ...previous, operationId, stage: "failed", targetProductVersion, restartVerified: false, cancelRequested: true, error: String(error?.message || error).slice(0, 4000), cancelledAt: now, failedAt: now, updatedAt: now }; const temporary = statePath + "." + process.pid + ".cancel.tmp"; fs.writeFileSync(temporary, JSON.stringify(next, null, 2), { encoding: "utf8", mode: 0o600 }); fs.renameSync(temporary, statePath); return "cancelled"; });',
|
|
1578
|
-
'const terminateTree = async child => { const pid = Number(child?.pid || 0); if (pid <= 1) return; if (process.platform === "win32") {
|
|
1592
|
+
'const terminateTree = async child => { const pid = Number(child?.pid || 0); if (pid <= 1) return; if (process.platform === "win32") { const root = child.livedeskWindowsIdentity || await child.livedeskWindowsIdentityPromise; if (!root && !child.livedeskWindowsRootExitedAtMs) { child.livedeskWindowsTrackingError = child.livedeskWindowsIdentityError || new Error(`No immutable Windows identity is available for pid=${pid}`); while (!child.livedeskWindowsRootExitedAtMs && !child.livedeskWindowsIdentity) await sleep(250); } await terminateExactWindowsTrackedTree(child, child.livedeskWindowsIdentity || root || null); return; } try { process.kill(-pid, "SIGTERM"); } catch { try { child.kill("SIGTERM"); } catch {} } const gracefulDeadline = Date.now() + 2000; while (Date.now() < gracefulDeadline && (isGroupAlive(pid) || isAlive(pid))) await sleep(100); if (isGroupAlive(pid) || isAlive(pid)) { try { process.kill(-pid, "SIGKILL"); } catch { try { child.kill("SIGKILL"); } catch {} } } };',
|
|
1579
1593
|
'const writeHandoffStarted = () => withStateLock(() => { if (deadlineExpired()) throw new Error("The absolute LiveDesk Client update deadline expired before handoff."); const previous = readState(); if (previous.operationId !== operationId) throw new Error("LiveDesk update handoff was superseded before exact-package launch."); const now = new Date().toISOString(); const next = { ...previous, operationId, stage: "handoff-started", starterPid: process.pid, updateDeadlineEpochMs, restartVerified: false, error: "", updatedAt: now }; const temporary = statePath + "." + process.pid + ".handoff-started.tmp"; fs.writeFileSync(temporary, JSON.stringify(next, null, 2), { encoding: "utf8", mode: 0o600 }); fs.renameSync(temporary, statePath); });',
|
|
1580
|
-
'const monitorPreflight = child => { let settled = false; const finish = (error, preserveFailure = false) => { if (settled) return; settled = true; clearInterval(poll); clearTimeout(timeout); child.unref(); if (preserveFailure) process.exitCode = 1; else if (error) writeFailure(error); }; const inspect = () => { const state = readState(); if (state.operationId !== operationId) return; if (state.stage === "preflight-ready" || state.stage === "waiting-for-shutdown") finish(); else if (state.stage === "failed")
|
|
1594
|
+
'const monitorPreflight = child => { let settled = false; let draining = false; let drainPromise = null; const finish = (error, preserveFailure = false) => { if (settled) return; settled = true; clearInterval(poll); clearTimeout(timeout); stopWindowsTracking(child); child.unref(); if (preserveFailure) process.exitCode = 1; else if (error) writeFailure(error); }; const drainAndFinish = (error, preserveFailure = false) => { if (settled) return Promise.resolve(); draining = true; if (!drainPromise) drainPromise = terminateTree(child).then(() => finish(error, preserveFailure)); return drainPromise; }; const inspect = () => { if (draining) return; const state = readState(); if (state.operationId !== operationId) return; if (state.stage === "preflight-ready" || state.stage === "waiting-for-shutdown") finish(); else if (state.stage === "failed") void drainAndFinish(null, true); }; const cancelTimedOutHandoff = async () => { const error = new Error(deadlineExpired() ? "The absolute LiveDesk Client update deadline expired before exact-package preflight." : "Timed out waiting for exact-package update preflight."); draining = true; while (!settled) { let outcome; try { outcome = writeCancellation(error); } catch (lockError) { await drainAndFinish(lockError); return; } if (outcome === "ready") { draining = false; inspect(); return; } if (outcome === "failed" || outcome === "cancelled" || outcome === "superseded") { await drainAndFinish(null, true); return; } await sleep(25); } }; const poll = setInterval(inspect, 100); const timeoutMs = Math.max(1, Math.min(Math.max(1000, Number(process.env.LIVEDESK_CLIENT_UPDATE_HANDOFF_TIMEOUT_MS || 140000)), updateDeadlineEpochMs - Date.now())); const timeout = setTimeout(() => { void cancelTimedOutHandoff().catch(error => { void drainAndFinish(error); }); }, timeoutMs); child.once("exit", (code, signal) => { inspect(); if (settled || draining) return; const error = new Error("Exact-package update supervisor exited before preflight (code=" + (code ?? "none") + ", signal=" + (signal || "none") + ")."); void drainAndFinish(error); }); inspect(); };',
|
|
1581
1595
|
'const prepareNeutralCwd = () => { if (!neutralCwd) throw new Error("LiveDesk update neutral working directory is unavailable"); fs.mkdirSync(neutralCwd, { recursive: true }); const unexpectedEntry = fs.readdirSync(neutralCwd)[0]; if (unexpectedEntry) { const shadow = path.join(neutralCwd, unexpectedEntry); throw new Error("LiveDesk update neutral working directory is shadowed by " + shadow + ": " + neutralCwd); } };',
|
|
1582
1596
|
'if (!operationId || !versionPattern.test(targetProductVersion) || deadlineExpired()) { writeFailure(new Error("Invalid or expired LiveDesk exact-package update handoff."), true); } else { try { prepareNeutralCwd(); writeHandoffStarted();',
|
|
1583
1597
|
'const env = { ...process.env, LIVEDESK_UPDATE_STARTER_PID: String(process.pid) }; const isolated = new Set(["init_cwd", "npm_config_local_prefix", "npm_config_workspace", "npm_config_workspaces", "npm_config_include_workspace_root", "npm_package_json", "npm_lifecycle_event", "npm_lifecycle_script"]); for (const key of Object.keys(env)) if (isolated.has(key.toLowerCase())) delete env[key]; env.INIT_CWD = neutralCwd; env.npm_config_local_prefix = neutralCwd; env.npm_config_workspaces = "false"; env.npm_config_include_workspace_root = "false";',
|
|
@@ -1587,7 +1601,7 @@ function buildClientUpdateBootstrapScript() {
|
|
|
1587
1601
|
'const args = ["-y", "--prefer-online", "--prefix", neutralCwd, "--workspaces=false", "livedesk@" + targetProductVersion, "--internal-legacy-client-update"];',
|
|
1588
1602
|
'const quoteCmd = value => "\\"" + String(value).replaceAll("%", "%%").replaceAll("\\"", "\\"\\"") + "\\"";',
|
|
1589
1603
|
'const invocation = npxCli ? { command: process.execPath, args: [npxCli, ...args] } : process.platform === "win32" ? { command: env.ComSpec || "cmd.exe", args: ["/d", "/s", "/c", "call " + quoteCmd(npx) + " " + args.map(quoteCmd).join(" ")] } : { command: npx, args };',
|
|
1590
|
-
'if (!npxCli && path.isAbsolute(npx) && !fs.existsSync(npx)) { writeFailure(new Error("LiveDesk npx executable was not found: " + npx)); } else { try { const child = spawn(invocation.command, invocation.args, { cwd: neutralCwd, env, detached: true, stdio: "ignore", windowsHide: true }); child.once("error", writeFailure); child.once("spawn", () => monitorPreflight(child)); } catch (error) { writeFailure(error); } }',
|
|
1604
|
+
'if (!npxCli && path.isAbsolute(npx) && !fs.existsSync(npx)) { writeFailure(new Error("LiveDesk npx executable was not found: " + npx)); } else { try { const child = spawn(invocation.command, invocation.args, { cwd: neutralCwd, env, detached: true, stdio: "ignore", windowsHide: true }); trackWindowsChild(child); child.once("error", writeFailure); child.once("spawn", () => { trackWindowsChild(child); monitorPreflight(child); }); } catch (error) { writeFailure(error); } }',
|
|
1591
1605
|
'} catch (error) { writeFailure(error, deadlineExpired()); } }',
|
|
1592
1606
|
''
|
|
1593
1607
|
].join('\n');
|
package/bin/livedesk-client.js
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
installAgentTerminationHandlers,
|
|
19
19
|
signalAgentTree
|
|
20
20
|
} from '../src/runtime/agent-process-lifecycle.js';
|
|
21
|
+
import { writeWindowsOwnedProcessManifest } from '../src/runtime/windows-owned-process-manifest.js';
|
|
21
22
|
import { createHubWakeListener } from '../src/runtime/hub-wake-listener.js';
|
|
22
23
|
import {
|
|
23
24
|
inspectLinuxVideoAcceleration,
|
|
@@ -73,7 +74,69 @@ let linuxVideoAccelerationStatus = null;
|
|
|
73
74
|
let discoveryWakeController = new AbortController();
|
|
74
75
|
let networkChangeMonitor = null;
|
|
75
76
|
const sessionRefreshesInFlight = new WeakMap();
|
|
76
|
-
installAgentTerminationHandlers({
|
|
77
|
+
const disposeAgentTerminationHandlers = installAgentTerminationHandlers({
|
|
78
|
+
getAgentProcess: () => activeAgentProcess
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
function readWindowsManifestOwnerFromEnvironment(env = process.env) {
|
|
82
|
+
if (process.platform !== 'win32') return null;
|
|
83
|
+
const rawOwner = {
|
|
84
|
+
pid: String(env.LIVEDESK_RUNTIME_OWNER_PID || '').trim(),
|
|
85
|
+
ownerToken: String(env.LIVEDESK_RUNTIME_OWNER_TOKEN || '').trim(),
|
|
86
|
+
ownerInstanceMarker: String(env.LIVEDESK_RUNTIME_OWNER_INSTANCE_MARKER || '').trim(),
|
|
87
|
+
ownerStartOrder: String(env.LIVEDESK_RUNTIME_OWNER_START_ORDER || '').trim()
|
|
88
|
+
};
|
|
89
|
+
const hasAnyOwnerField = Object.values(rawOwner).some(Boolean);
|
|
90
|
+
if (!hasAnyOwnerField) return null;
|
|
91
|
+
const owner = {
|
|
92
|
+
...rawOwner,
|
|
93
|
+
pid: Number(rawOwner.pid)
|
|
94
|
+
};
|
|
95
|
+
if (owner.pid !== process.pid
|
|
96
|
+
|| !/^[a-f0-9]{32}$/i.test(owner.ownerToken)
|
|
97
|
+
|| !owner.ownerInstanceMarker.startsWith(`${process.pid}:`)
|
|
98
|
+
|| !/^\d+$/.test(owner.ownerStartOrder)) {
|
|
99
|
+
console.warn(
|
|
100
|
+
'[LiveDesk Client] Windows process-manifest publication is disabled because '
|
|
101
|
+
+ 'the unified launcher owner tuple is incomplete or does not name this exact process.'
|
|
102
|
+
);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
return owner;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const WINDOWS_PROCESS_MANIFEST_OWNER = readWindowsManifestOwnerFromEnvironment();
|
|
109
|
+
|
|
110
|
+
function publishWindowsOwnedAgentRecords(records, {
|
|
111
|
+
rootRecord,
|
|
112
|
+
snapshot
|
|
113
|
+
} = {}) {
|
|
114
|
+
if (!WINDOWS_PROCESS_MANIFEST_OWNER || !rootRecord || records.length === 0) return;
|
|
115
|
+
const ownerRecord = (Array.isArray(snapshot) ? snapshot : []).find(
|
|
116
|
+
record => Number(record?.pid || 0) === WINDOWS_PROCESS_MANIFEST_OWNER.pid
|
|
117
|
+
);
|
|
118
|
+
const exactOwnerGeneration = ownerRecord
|
|
119
|
+
&& ownerRecord.startOrder === WINDOWS_PROCESS_MANIFEST_OWNER.ownerStartOrder
|
|
120
|
+
&& `${ownerRecord.pid}:${ownerRecord.startMarker}`
|
|
121
|
+
=== WINDOWS_PROCESS_MANIFEST_OWNER.ownerInstanceMarker;
|
|
122
|
+
if (!exactOwnerGeneration) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
'The Windows process snapshot did not confirm the unified launcher owner generation.'
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
const publication = writeWindowsOwnedProcessManifest({
|
|
128
|
+
stateDir: UNIFIED_CLIENT_STATE_DIR,
|
|
129
|
+
owner: WINDOWS_PROCESS_MANIFEST_OWNER,
|
|
130
|
+
records
|
|
131
|
+
});
|
|
132
|
+
if (!publication.ok) {
|
|
133
|
+
throw publication.error || new Error('Windows owned-process manifest publication failed.');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function requestClientRuntimeShutdown(signal = 'SIGTERM') {
|
|
138
|
+
disposeAgentTerminationHandlers.requestTermination(signal);
|
|
139
|
+
}
|
|
77
140
|
|
|
78
141
|
function requestActiveAgentStop(signal = 'SIGTERM') {
|
|
79
142
|
const child = activeAgentProcess;
|
|
@@ -3233,8 +3296,8 @@ async function chooseClientConnection(supabase, options = {}) {
|
|
|
3233
3296
|
}, 150);
|
|
3234
3297
|
return { ...result, slotNumber, saved: true, restarting: true };
|
|
3235
3298
|
},
|
|
3236
|
-
onRestart: () =>
|
|
3237
|
-
onShutdown: () =>
|
|
3299
|
+
onRestart: () => requestClientRuntimeShutdown('SIGTERM'),
|
|
3300
|
+
onShutdown: () => requestClientRuntimeShutdown('SIGTERM'),
|
|
3238
3301
|
onDisconnect: () => requestActiveAgentStop(),
|
|
3239
3302
|
// Ending the current Agent causes the unified lifecycle loop to
|
|
3240
3303
|
// discard its old manager/pair token and start Supabase discovery
|
|
@@ -4308,7 +4371,10 @@ function spawnAgent(command, args, env = process.env, onStart = null) {
|
|
|
4308
4371
|
});
|
|
4309
4372
|
child.livedeskOwnsProcessGroup = ownsProcessGroup;
|
|
4310
4373
|
child.livedeskWindowsTreeTracker = createWindowsProcessTreeTracker(child, {
|
|
4311
|
-
spawnedAtMs: agentSpawnedAtMs
|
|
4374
|
+
spawnedAtMs: agentSpawnedAtMs,
|
|
4375
|
+
publishTrackedRecords: WINDOWS_PROCESS_MANIFEST_OWNER
|
|
4376
|
+
? publishWindowsOwnedAgentRecords
|
|
4377
|
+
: null
|
|
4312
4378
|
});
|
|
4313
4379
|
activeAgentProcess = child;
|
|
4314
4380
|
if (typeof onStart === 'function') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@livedesk/client",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.203",
|
|
4
4
|
"description": "LiveDesk local remote client",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,10 +41,10 @@
|
|
|
41
41
|
"ws": "^8.18.3"
|
|
42
42
|
},
|
|
43
43
|
"optionalDependencies": {
|
|
44
|
-
"@livedesk/fast-linux-x64": "0.1.
|
|
45
|
-
"@livedesk/fast-osx-arm64": "0.1.
|
|
46
|
-
"@livedesk/fast-osx-x64": "0.1.
|
|
47
|
-
"@livedesk/fast-win-x64": "0.1.
|
|
44
|
+
"@livedesk/fast-linux-x64": "0.1.411",
|
|
45
|
+
"@livedesk/fast-osx-arm64": "0.1.411",
|
|
46
|
+
"@livedesk/fast-osx-x64": "0.1.411",
|
|
47
|
+
"@livedesk/fast-win-x64": "0.1.411"
|
|
48
48
|
},
|
|
49
49
|
"publishConfig": {
|
|
50
50
|
"access": "public"
|
|
@@ -30,6 +30,14 @@ function windowsTicksForUnixMilliseconds(milliseconds) {
|
|
|
30
30
|
return BigInt(Math.trunc(Number(milliseconds) || 0)) * 10_000n + 621_355_968_000_000_000n;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
function unixMillisecondsForWindowsTicks(ticks) {
|
|
34
|
+
try {
|
|
35
|
+
return Number((BigInt(ticks) - 621_355_968_000_000_000n) / 10_000n);
|
|
36
|
+
} catch {
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
33
41
|
function windowsRecordIsWithinLifetime(record, spawnedAtMs, exitedAtMs = null) {
|
|
34
42
|
if (!record?.startOrder) return false;
|
|
35
43
|
try {
|
|
@@ -72,7 +80,7 @@ export async function queryWindowsProcessTable({
|
|
|
72
80
|
} = {}) {
|
|
73
81
|
const script = [
|
|
74
82
|
'$ErrorActionPreference = "Stop";',
|
|
75
|
-
'@(Get-CimInstance Win32_Process | ForEach-Object {',
|
|
83
|
+
'@(Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,CreationDate | ForEach-Object {',
|
|
76
84
|
' $creation = [string]$_.CreationDate;',
|
|
77
85
|
' $ticks = if ($_.CreationDate) { [string]$_.CreationDate.ToUniversalTime().Ticks } else { "" };',
|
|
78
86
|
' [pscustomobject]@{ ProcessId = [int]$_.ProcessId; ParentProcessId = [int]$_.ParentProcessId; CreationDate = $creation; CreationUtcTicks = $ticks }',
|
|
@@ -102,31 +110,42 @@ export async function queryWindowsProcessTable({
|
|
|
102
110
|
}
|
|
103
111
|
|
|
104
112
|
/**
|
|
105
|
-
* Rechecks CreationDate
|
|
106
|
-
*
|
|
107
|
-
* of terminating the replacement process.
|
|
113
|
+
* Rechecks CreationDate and force-stops the single PID inside the same
|
|
114
|
+
* PowerShell process. PID reuse therefore becomes a harmless identity mismatch
|
|
115
|
+
* instead of terminating the replacement process.
|
|
108
116
|
*/
|
|
109
|
-
export async function terminateExactWindowsProcessTree(
|
|
110
|
-
includeTree = true,
|
|
117
|
+
export async function terminateExactWindowsProcessTree(recordOrRecords, {
|
|
111
118
|
execFileImpl = execFile
|
|
112
119
|
} = {}) {
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
120
|
+
const targets = (Array.isArray(recordOrRecords) ? recordOrRecords : [recordOrRecords])
|
|
121
|
+
.map(normalizeWindowsProcessRecord)
|
|
122
|
+
.filter(Boolean);
|
|
123
|
+
if (targets.length === 0) {
|
|
124
|
+
return { ok: false, error: new Error('At least one immutable Windows process record is required.') };
|
|
116
125
|
}
|
|
117
|
-
const
|
|
118
|
-
|
|
126
|
+
const targetsJson = JSON.stringify(targets.map(target => ({
|
|
127
|
+
pid: target.pid,
|
|
128
|
+
startOrder: target.startOrder
|
|
129
|
+
}))).replaceAll("'", "''");
|
|
119
130
|
const script = [
|
|
120
131
|
'$ErrorActionPreference = "Stop";',
|
|
121
|
-
`$
|
|
122
|
-
|
|
123
|
-
'$
|
|
124
|
-
'
|
|
125
|
-
'$
|
|
126
|
-
'
|
|
127
|
-
|
|
128
|
-
'
|
|
129
|
-
'
|
|
132
|
+
`$targets = ConvertFrom-Json -InputObject '${targetsJson}';`,
|
|
133
|
+
'$failures = [System.Collections.Generic.List[string]]::new();',
|
|
134
|
+
'foreach ($item in $targets) {',
|
|
135
|
+
' $targetPid = [int]$item.pid;',
|
|
136
|
+
' $expectedStartTicks = [string]$item.startOrder;',
|
|
137
|
+
' try {',
|
|
138
|
+
' $target = Get-CimInstance Win32_Process -Filter "ProcessId = $targetPid" -Property ProcessId,CreationDate -ErrorAction SilentlyContinue | Select-Object -First 1;',
|
|
139
|
+
' if (-not $target) { continue };',
|
|
140
|
+
' $targetStartTicks = if ($target.CreationDate) { [string]$target.CreationDate.ToUniversalTime().Ticks } else { "" };',
|
|
141
|
+
' if ($targetStartTicks -ne $expectedStartTicks) { continue };',
|
|
142
|
+
' Stop-Process -Id $targetPid -Force -ErrorAction Stop;',
|
|
143
|
+
' } catch {',
|
|
144
|
+
' $failures.Add("pid=$targetPid $($_.Exception.Message)");',
|
|
145
|
+
' }',
|
|
146
|
+
'}',
|
|
147
|
+
'if ($failures.Count -gt 0) { [Console]::Error.WriteLine(($failures -join "; ")); exit 1 };',
|
|
148
|
+
'exit 0;'
|
|
130
149
|
].join(' ');
|
|
131
150
|
return runExecFile(
|
|
132
151
|
'powershell.exe',
|
|
@@ -148,7 +167,11 @@ export function createWindowsProcessTreeTracker(child, {
|
|
|
148
167
|
snapshotIntervalMs = DEFAULT_WINDOWS_TREE_SNAPSHOT_MS,
|
|
149
168
|
spawnedAtMs = Date.now(),
|
|
150
169
|
setIntervalImpl = setInterval,
|
|
151
|
-
clearIntervalImpl = clearInterval
|
|
170
|
+
clearIntervalImpl = clearInterval,
|
|
171
|
+
publishTrackedRecords = null,
|
|
172
|
+
reportPublisherError = error => console.warn(
|
|
173
|
+
`[LiveDesk Client] Windows owned-process manifest warning: ${error?.message || error}`
|
|
174
|
+
)
|
|
152
175
|
} = {}) {
|
|
153
176
|
const rootPid = Number(child?.pid || 0);
|
|
154
177
|
const enabled = platform === 'win32' && Number.isInteger(rootPid) && rootPid > 1;
|
|
@@ -164,19 +187,33 @@ export function createWindowsProcessTreeTracker(child, {
|
|
|
164
187
|
let lastSnapshotHadRootPid = false;
|
|
165
188
|
let successfulSnapshotAfterExit = false;
|
|
166
189
|
let ambiguousRootReuse = false;
|
|
190
|
+
let lastPublisherError = null;
|
|
167
191
|
|
|
168
192
|
const mergeSnapshot = snapshot => {
|
|
169
193
|
const records = (Array.isArray(snapshot) ? snapshot : [])
|
|
170
194
|
.map(normalizeWindowsProcessRecord)
|
|
171
195
|
.filter(Boolean);
|
|
172
196
|
lastSnapshot = records;
|
|
173
|
-
if (rootExitedAtMs !== null) successfulSnapshotAfterExit = true;
|
|
174
197
|
const currentByPid = new Map(records.map(record => [record.pid, record]));
|
|
175
198
|
const currentRoot = currentByPid.get(rootPid) || null;
|
|
176
199
|
lastSnapshotHadRootPid = Boolean(currentRoot);
|
|
177
|
-
|
|
200
|
+
const rootWasReused = Boolean(
|
|
201
|
+
rootRecord && currentRoot && !sameWindowsProcess(currentRoot, rootRecord)
|
|
202
|
+
);
|
|
203
|
+
if (rootWasReused) {
|
|
178
204
|
ambiguousRootReuse = true;
|
|
179
205
|
}
|
|
206
|
+
if (rootRecord && rootExitedAtMs === null && (!currentRoot || rootWasReused)) {
|
|
207
|
+
// The exit event can trail the first CIM snapshot that proves the
|
|
208
|
+
// exact root is gone. Use that proof immediately so a surviving
|
|
209
|
+
// child first observed in this same snapshot is still owned.
|
|
210
|
+
// For PID reuse, cap the old lifetime just before the replacement
|
|
211
|
+
// CreationDate so replacement-root children remain excluded.
|
|
212
|
+
rootExitedAtMs = rootWasReused
|
|
213
|
+
? unixMillisecondsForWindowsTicks(currentRoot.startOrder) - 1
|
|
214
|
+
: Date.now();
|
|
215
|
+
}
|
|
216
|
+
if (rootExitedAtMs !== null) successfulSnapshotAfterExit = true;
|
|
180
217
|
if (!rootRecord
|
|
181
218
|
&& currentRoot
|
|
182
219
|
&& windowsRecordIsWithinLifetime(currentRoot, spawnedAtMs, rootExitedAtMs)) {
|
|
@@ -202,9 +239,11 @@ export function createWindowsProcessTreeTracker(child, {
|
|
|
202
239
|
}
|
|
203
240
|
}
|
|
204
241
|
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
242
|
+
// Windows retains ParentProcessId on surviving direct children after
|
|
243
|
+
// the root exits. A reused root PID is also safe to bridge only for a
|
|
244
|
+
// direct child whose immutable creation time is inside the original
|
|
245
|
+
// root lifetime; children created by the replacement root are rejected
|
|
246
|
+
// by windowsRecordIsWithinLifetime.
|
|
208
247
|
const rootAbsent = !currentRoot;
|
|
209
248
|
let changed = true;
|
|
210
249
|
while (changed) {
|
|
@@ -215,7 +254,7 @@ export function createWindowsProcessTreeTracker(child, {
|
|
|
215
254
|
let parent = exactCurrentByPid.get(record.parentPid) || null;
|
|
216
255
|
if (!parent
|
|
217
256
|
&& rootExitedAtMs !== null
|
|
218
|
-
&& rootAbsent
|
|
257
|
+
&& (rootAbsent || rootWasReused)
|
|
219
258
|
&& record.parentPid === rootPid) {
|
|
220
259
|
parent = rootRecord || { pid: rootPid, depth: 0 };
|
|
221
260
|
}
|
|
@@ -233,10 +272,34 @@ export function createWindowsProcessTreeTracker(child, {
|
|
|
233
272
|
if (inFlight) return inFlight;
|
|
234
273
|
inFlight = Promise.resolve()
|
|
235
274
|
.then(() => queryProcessTableImpl())
|
|
236
|
-
.then(snapshot => {
|
|
275
|
+
.then(async snapshot => {
|
|
237
276
|
mergeSnapshot(snapshot);
|
|
238
277
|
lastError = null;
|
|
239
|
-
|
|
278
|
+
if (typeof publishTrackedRecords === 'function') {
|
|
279
|
+
try {
|
|
280
|
+
await publishTrackedRecords(
|
|
281
|
+
[...tracked.values()].map(record => ({ ...record })),
|
|
282
|
+
{
|
|
283
|
+
rootPid,
|
|
284
|
+
rootRecord: rootRecord ? { ...rootRecord } : null,
|
|
285
|
+
snapshot: lastSnapshot.map(record => ({ ...record })),
|
|
286
|
+
terminal: stopped
|
|
287
|
+
}
|
|
288
|
+
);
|
|
289
|
+
lastPublisherError = null;
|
|
290
|
+
} catch (error) {
|
|
291
|
+
// Manifest publication must never break the in-memory
|
|
292
|
+
// cleanup proof. Keep tracking, surface diagnostics,
|
|
293
|
+
// and retry on the next cadence/terminal refresh.
|
|
294
|
+
lastPublisherError = error;
|
|
295
|
+
try { reportPublisherError(error); } catch { /* diagnostics cannot own lifecycle */ }
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
ok: true,
|
|
300
|
+
records: lastSnapshot,
|
|
301
|
+
publisherError: lastPublisherError
|
|
302
|
+
};
|
|
240
303
|
})
|
|
241
304
|
.catch(error => {
|
|
242
305
|
lastError = error;
|
|
@@ -285,7 +348,8 @@ export function createWindowsProcessTreeTracker(child, {
|
|
|
285
348
|
.filter(record => sameWindowsProcess(currentByPid.get(record.pid), record))
|
|
286
349
|
.map(record => ({ ...record }));
|
|
287
350
|
},
|
|
288
|
-
getLastError: () => lastError
|
|
351
|
+
getLastError: () => lastError,
|
|
352
|
+
getLastPublisherError: () => lastPublisherError
|
|
289
353
|
};
|
|
290
354
|
}
|
|
291
355
|
|
|
@@ -345,17 +409,18 @@ export async function drainOwnedWindowsAgentTree(child, {
|
|
|
345
409
|
} else {
|
|
346
410
|
const remaining = tracker.getCurrentExactRecords();
|
|
347
411
|
if (remaining.length === 0
|
|
348
|
-
&& tracker.hasSafeFinalSnapshot?.()
|
|
349
|
-
&& !tracker.hasAmbiguousRootReuse?.()) {
|
|
412
|
+
&& tracker.hasSafeFinalSnapshot?.()) {
|
|
350
413
|
return { ok: true, remaining: [] };
|
|
351
414
|
}
|
|
352
415
|
const ordered = [...remaining].sort((left, right) => (
|
|
353
|
-
Number(
|
|
416
|
+
Number(right.depth || 0) - Number(left.depth || 0)
|
|
354
417
|
));
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
418
|
+
// One PowerShell invocation receives the descendant-first
|
|
419
|
+
// immutable snapshot, then rechecks every PID CreationDate
|
|
420
|
+
// immediately before its own Stop-Process. No taskkill /T
|
|
421
|
+
// traversal can follow an unverified stale PPID edge.
|
|
422
|
+
const termination = await terminateExactTreeImpl(ordered, { includeTree: false });
|
|
423
|
+
if (termination?.ok === false) lastError = termination.error || lastError;
|
|
359
424
|
}
|
|
360
425
|
if (attempt < attempts) {
|
|
361
426
|
await waitImpl(Math.max(25, Number(retryMs) || 100));
|
|
@@ -366,8 +431,7 @@ export async function drainOwnedWindowsAgentTree(child, {
|
|
|
366
431
|
const remaining = finalRefresh.ok ? tracker.getCurrentExactRecords() : tracker.getTrackedRecords();
|
|
367
432
|
if (finalRefresh.ok
|
|
368
433
|
&& remaining.length === 0
|
|
369
|
-
&& tracker.hasSafeFinalSnapshot?.()
|
|
370
|
-
&& !tracker.hasAmbiguousRootReuse?.()) {
|
|
434
|
+
&& tracker.hasSafeFinalSnapshot?.()) {
|
|
371
435
|
return { ok: true, remaining: [] };
|
|
372
436
|
}
|
|
373
437
|
const error = finalRefresh.error || lastError || new Error(
|
|
@@ -717,9 +781,18 @@ export function installAgentTerminationHandlers({
|
|
|
717
781
|
hostProcess.on(signal, handler);
|
|
718
782
|
}
|
|
719
783
|
|
|
720
|
-
|
|
784
|
+
const dispose = () => {
|
|
721
785
|
for (const [signal, handler] of handlers) {
|
|
722
786
|
hostProcess.removeListener(signal, handler);
|
|
723
787
|
}
|
|
724
788
|
};
|
|
789
|
+
// Windows process.kill(pid, 'SIGTERM') can terminate a process without
|
|
790
|
+
// dispatching its JavaScript SIGTERM listener. Replacement startup must
|
|
791
|
+
// enter this exact cleanup state machine directly instead of simulating an
|
|
792
|
+
// operating-system signal.
|
|
793
|
+
dispose.requestTermination = (signal = 'SIGTERM') => {
|
|
794
|
+
const normalizedSignal = signal === 'SIGINT' ? 'SIGINT' : 'SIGTERM';
|
|
795
|
+
handlers.get(normalizedSignal)?.();
|
|
796
|
+
};
|
|
797
|
+
return dispose;
|
|
725
798
|
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
unlinkSync,
|
|
8
|
+
writeFileSync
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
|
|
13
|
+
export const WINDOWS_OWNED_PROCESS_MANIFEST_VERSION = 1;
|
|
14
|
+
export const DEFAULT_WINDOWS_OWNED_PROCESS_MANIFEST_MAX_AGE_MS = 30_000;
|
|
15
|
+
|
|
16
|
+
const WINDOWS_OWNED_PROCESS_MANIFEST_FILE_NAME = 'client-owned-windows-processes.json';
|
|
17
|
+
const MAX_FUTURE_CLOCK_SKEW_MS = 5_000;
|
|
18
|
+
|
|
19
|
+
function resultError(message) {
|
|
20
|
+
return new Error(message);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolveNow(now) {
|
|
24
|
+
const value = typeof now === 'function' ? now() : now;
|
|
25
|
+
const milliseconds = value === undefined ? Date.now() : Number(value);
|
|
26
|
+
return Number.isFinite(milliseconds) ? milliseconds : NaN;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeOwner(value) {
|
|
30
|
+
const owner = {
|
|
31
|
+
pid: Number(value?.pid || 0),
|
|
32
|
+
ownerToken: String(value?.ownerToken || '').trim(),
|
|
33
|
+
ownerInstanceMarker: String(value?.ownerInstanceMarker || '').trim(),
|
|
34
|
+
ownerStartOrder: String(value?.ownerStartOrder || '').trim()
|
|
35
|
+
};
|
|
36
|
+
if (!Number.isInteger(owner.pid)
|
|
37
|
+
|| owner.pid <= 1
|
|
38
|
+
|| !/^[a-f0-9]{32}$/i.test(owner.ownerToken)
|
|
39
|
+
|| !owner.ownerInstanceMarker.startsWith(`${owner.pid}:`)
|
|
40
|
+
|| !/^\d+$/.test(owner.ownerStartOrder)) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return owner;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function ownersMatch(leftValue, rightValue) {
|
|
47
|
+
const left = normalizeOwner(leftValue);
|
|
48
|
+
const right = normalizeOwner(rightValue);
|
|
49
|
+
return !!left
|
|
50
|
+
&& !!right
|
|
51
|
+
&& left.pid === right.pid
|
|
52
|
+
&& left.ownerToken === right.ownerToken
|
|
53
|
+
&& left.ownerInstanceMarker === right.ownerInstanceMarker
|
|
54
|
+
&& left.ownerStartOrder === right.ownerStartOrder;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizeRecord(value) {
|
|
58
|
+
const record = {
|
|
59
|
+
pid: Number(value?.pid ?? value?.ProcessId ?? 0),
|
|
60
|
+
parentPid: Number(value?.parentPid ?? value?.ParentProcessId ?? 0),
|
|
61
|
+
startMarker: String(value?.startMarker ?? value?.CreationDate ?? '').trim(),
|
|
62
|
+
startOrder: String(value?.startOrder ?? value?.CreationUtcTicks ?? '').trim(),
|
|
63
|
+
depth: Math.max(0, Math.trunc(Number(value?.depth || 0)))
|
|
64
|
+
};
|
|
65
|
+
if (!Number.isInteger(record.pid)
|
|
66
|
+
|| record.pid <= 1
|
|
67
|
+
|| !Number.isInteger(record.parentPid)
|
|
68
|
+
|| record.parentPid < 0
|
|
69
|
+
|| !record.startMarker
|
|
70
|
+
|| !/^\d+$/.test(record.startOrder)
|
|
71
|
+
|| !Number.isFinite(record.depth)) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
return record;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalizeRecords(values) {
|
|
78
|
+
if (!Array.isArray(values)) return null;
|
|
79
|
+
const records = values.map(normalizeRecord);
|
|
80
|
+
if (records.some(record => !record)) return null;
|
|
81
|
+
const unique = new Map();
|
|
82
|
+
for (const record of records) {
|
|
83
|
+
unique.set(`${record.pid}:${record.startOrder}`, record);
|
|
84
|
+
}
|
|
85
|
+
return [...unique.values()];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function writeJsonAtomic(path, value) {
|
|
89
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
90
|
+
const temporaryPath = `${path}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
|
|
91
|
+
let published = false;
|
|
92
|
+
try {
|
|
93
|
+
writeFileSync(temporaryPath, JSON.stringify(value, null, 2), {
|
|
94
|
+
encoding: 'utf8',
|
|
95
|
+
mode: 0o600,
|
|
96
|
+
flag: 'wx'
|
|
97
|
+
});
|
|
98
|
+
renameSync(temporaryPath, path);
|
|
99
|
+
// Existing state directories can have been created by older versions;
|
|
100
|
+
// enforce the owner-only contract after every atomic replacement too.
|
|
101
|
+
try { chmodSync(path, 0o600); } catch { /* Windows ACLs remain authoritative */ }
|
|
102
|
+
published = true;
|
|
103
|
+
} finally {
|
|
104
|
+
if (!published) {
|
|
105
|
+
try { unlinkSync(temporaryPath); } catch { /* temporary file was never published */ }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function getWindowsOwnedProcessManifestPath(
|
|
111
|
+
stateDir = join(os.homedir(), '.livedesk')
|
|
112
|
+
) {
|
|
113
|
+
return join(stateDir, WINDOWS_OWNED_PROCESS_MANIFEST_FILE_NAME);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Publishes one immutable launcher generation and all exact Agent records.
|
|
118
|
+
* Every record retains PID + full-precision CreationDate ticks so a later
|
|
119
|
+
* launcher can harmlessly ignore a PID that has since been reused.
|
|
120
|
+
*/
|
|
121
|
+
export function writeWindowsOwnedProcessManifest({
|
|
122
|
+
stateDir,
|
|
123
|
+
owner,
|
|
124
|
+
records,
|
|
125
|
+
now
|
|
126
|
+
} = {}) {
|
|
127
|
+
const exactOwner = normalizeOwner(owner);
|
|
128
|
+
const exactRecords = normalizeRecords(records);
|
|
129
|
+
const nowMs = resolveNow(now);
|
|
130
|
+
if (!exactOwner) {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
error: resultError('An exact Windows LiveDesk launcher owner is required.')
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (!exactRecords) {
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
error: resultError('Every Windows LiveDesk process record requires exact PID and CreationDate identity.')
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
if (!Number.isFinite(nowMs)) {
|
|
143
|
+
return { ok: false, error: resultError('A valid manifest publication time is required.') };
|
|
144
|
+
}
|
|
145
|
+
const path = getWindowsOwnedProcessManifestPath(stateDir);
|
|
146
|
+
const updatedAt = new Date(nowMs).toISOString();
|
|
147
|
+
try {
|
|
148
|
+
writeJsonAtomic(path, {
|
|
149
|
+
protocolVersion: WINDOWS_OWNED_PROCESS_MANIFEST_VERSION,
|
|
150
|
+
owner: exactOwner,
|
|
151
|
+
updatedAt,
|
|
152
|
+
records: exactRecords
|
|
153
|
+
});
|
|
154
|
+
return { ok: true, path, records: exactRecords, updatedAt };
|
|
155
|
+
} catch (error) {
|
|
156
|
+
return { ok: false, path, records: [], error };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Reads only a fresh manifest belonging to the exact runtime-lock generation.
|
|
162
|
+
* Owner mismatch, malformed identity, expiry, and PID reuse all fail closed.
|
|
163
|
+
*/
|
|
164
|
+
export function readWindowsOwnedProcessManifest({
|
|
165
|
+
stateDir,
|
|
166
|
+
owner,
|
|
167
|
+
maxAgeMs = DEFAULT_WINDOWS_OWNED_PROCESS_MANIFEST_MAX_AGE_MS,
|
|
168
|
+
now
|
|
169
|
+
} = {}) {
|
|
170
|
+
const exactOwner = normalizeOwner(owner);
|
|
171
|
+
const nowMs = resolveNow(now);
|
|
172
|
+
const maximumAge = Number(maxAgeMs);
|
|
173
|
+
const path = getWindowsOwnedProcessManifestPath(stateDir);
|
|
174
|
+
if (!exactOwner) {
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
records: [],
|
|
178
|
+
error: resultError('An exact Windows LiveDesk launcher owner is required.')
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
if (!Number.isFinite(nowMs)
|
|
182
|
+
|| !Number.isFinite(maximumAge)
|
|
183
|
+
|| maximumAge < 0) {
|
|
184
|
+
return {
|
|
185
|
+
ok: false,
|
|
186
|
+
records: [],
|
|
187
|
+
error: resultError('A valid manifest time and maximum age are required.')
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let parsed;
|
|
192
|
+
try {
|
|
193
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
194
|
+
} catch (error) {
|
|
195
|
+
return { ok: false, records: [], error };
|
|
196
|
+
}
|
|
197
|
+
const updatedAt = String(parsed?.updatedAt || '');
|
|
198
|
+
const updatedAtMs = Date.parse(updatedAt);
|
|
199
|
+
if (parsed?.protocolVersion !== WINDOWS_OWNED_PROCESS_MANIFEST_VERSION) {
|
|
200
|
+
return {
|
|
201
|
+
ok: false,
|
|
202
|
+
records: [],
|
|
203
|
+
updatedAt,
|
|
204
|
+
error: resultError('Unsupported Windows LiveDesk process-manifest version.')
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
if (!ownersMatch(parsed?.owner, exactOwner)) {
|
|
208
|
+
return {
|
|
209
|
+
ok: false,
|
|
210
|
+
records: [],
|
|
211
|
+
updatedAt,
|
|
212
|
+
error: resultError('Windows LiveDesk process manifest belongs to another launcher generation.')
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
if (!Number.isFinite(updatedAtMs)
|
|
216
|
+
|| updatedAtMs > nowMs + MAX_FUTURE_CLOCK_SKEW_MS
|
|
217
|
+
|| nowMs - updatedAtMs > maximumAge) {
|
|
218
|
+
return {
|
|
219
|
+
ok: false,
|
|
220
|
+
records: [],
|
|
221
|
+
updatedAt,
|
|
222
|
+
error: resultError('Windows LiveDesk process manifest is stale or has an invalid timestamp.')
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
const exactRecords = normalizeRecords(parsed?.records);
|
|
226
|
+
if (!exactRecords) {
|
|
227
|
+
return {
|
|
228
|
+
ok: false,
|
|
229
|
+
records: [],
|
|
230
|
+
updatedAt,
|
|
231
|
+
error: resultError('Windows LiveDesk process manifest contains an invalid process identity.')
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
return { ok: true, records: exactRecords, updatedAt };
|
|
235
|
+
}
|