@livedesk/client 0.1.215 → 0.1.217
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-client-node.js +506 -555
- package/bin/livedesk-client-update-bootstrap.cjs +59 -0
- package/bin/livedesk-client.js +195 -58
- package/package.json +11 -11
- package/src/runtime/agent-process-lifecycle.js +85 -23
- package/src/runtime/client-runtime-server.js +719 -131
- package/src/runtime/hub-wake-listener.js +42 -11
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const { execFile, spawn } = require("node:child_process");
|
|
2
|
+
const fs = require("node:fs");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
const bootstrapPath = String(process.env.LIVEDESK_CLIENT_UPDATE_BOOTSTRAP_PATH || "").trim(); if (bootstrapPath) { try { fs.rmSync(bootstrapPath, { force: true }); } catch {} }
|
|
5
|
+
const operationId = String(process.env.LIVEDESK_CLIENT_UPDATE_OPERATION_ID || "").trim();
|
|
6
|
+
const originalCwd = path.resolve(String(process.env.LIVEDESK_UPDATE_ORIGINAL_CWD || process.cwd()));
|
|
7
|
+
const statePath = path.resolve(originalCwd, String(process.env.LIVEDESK_CLIENT_UPDATE_STATE_PATH || path.join(require("node:os").homedir(), ".livedesk", "client-update.json")));
|
|
8
|
+
const neutralCwdValue = String(process.env.LIVEDESK_UPDATE_NEUTRAL_CWD || "").trim();
|
|
9
|
+
const neutralCwd = neutralCwdValue ? path.resolve(neutralCwdValue) : "";
|
|
10
|
+
const cleanVersion = value => String(value || "").trim().replace(/^v/i, "");
|
|
11
|
+
const targetProductVersion = cleanVersion(process.env.LIVEDESK_CLIENT_UPDATE_TARGET_PRODUCT_VERSION || process.env.LIVEDESK_CLIENT_UPDATE_TARGET_VERSION);
|
|
12
|
+
const updateDeadlineEpochMs = Number(process.env.LIVEDESK_CLIENT_UPDATE_DEADLINE_EPOCH_MS || 0);
|
|
13
|
+
const deadlineExpired = () => !Number.isSafeInteger(updateDeadlineEpochMs) || Date.now() >= updateDeadlineEpochMs;
|
|
14
|
+
const versionPattern = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
15
|
+
const readState = () => { try { return JSON.parse(fs.readFileSync(statePath, "utf8")); } catch { return {}; } };
|
|
16
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
17
|
+
const isAlive = pid => { try { process.kill(pid, 0); return true; } catch (error) { return error?.code === "EPERM"; } };
|
|
18
|
+
const isGroupAlive = pid => { try { process.kill(-pid, 0); return true; } catch (error) { return error?.code === "EPERM"; } };
|
|
19
|
+
const WINDOWS_PROCESS_VERIFY_MAX_CIM_QUERIES = 4; const WINDOWS_PROCESS_VERIFY_MAX_POWERSHELL_SPAWNS = 4; const WINDOWS_PROCESS_VERIFY_TIMEOUT_MS = 4000; const WINDOWS_PROCESS_VERIFY_MAX_TOTAL_CIM_MS = 8000; const WINDOWS_PROCESS_VERIFY_MAX_CPU_MS = 2000; const WINDOWS_PROCESS_VERIFY_MAX_RSS_BYTES = 256 * 1024 * 1024; const WINDOWS_PROCESS_VERIFY_MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
|
|
20
|
+
const windowsProcessVerification = { powerShellSpawnCount: 0, activePowerShellChildren: 0, maximumConcurrentPowerShellChildren: 0, powerShellResourceEvidenceCount: 0, powerShellResourceEvidenceMissingCount: 0, cimQueryCount: 0, activeCimQueries: 0, cimTotalDurationMs: 0, maximumCimProviderDurationMs: 0, totalPowerShellCpuMs: 0, maximumPowerShellCpuMs: 0, maximumPowerShellRssBytes: 0, activeVerificationWatchdogTimers: 0, stateReadCount: 0, stateWatchEventCount: 0, activeStateWatchers: 0, activeStateFallbackTimers: 0, cleanupCompletedCount: 0, cleanupFailureCount: 0, cleanupBlockedCount: 0, forcedCleanupProofFailureCount: 0, failClosedOwnershipHeld: false, failClosedOwnershipTimerCount: 0, terminalZeroProved: false, blockedExactIdentityCount: 0, blockedExactIdentities: [], directChildKillAttempted: false, directChildExitProved: false };
|
|
21
|
+
const assertWindowsVerificationBudget = () => { if (windowsProcessVerification.powerShellSpawnCount > WINDOWS_PROCESS_VERIFY_MAX_POWERSHELL_SPAWNS) throw new Error("Windows update cleanup exceeded its total PowerShell spawn budget."); if (windowsProcessVerification.cimQueryCount > WINDOWS_PROCESS_VERIFY_MAX_CIM_QUERIES) throw new Error("Windows update cleanup exceeded its total CIM query budget."); if (windowsProcessVerification.cimTotalDurationMs > WINDOWS_PROCESS_VERIFY_MAX_TOTAL_CIM_MS) throw new Error("Windows update cleanup exceeded its total CIM duration budget."); if (windowsProcessVerification.powerShellResourceEvidenceMissingCount !== 0 || windowsProcessVerification.powerShellResourceEvidenceCount !== windowsProcessVerification.powerShellSpawnCount) throw new Error("Windows update cleanup has unknown PowerShell resource evidence."); if (windowsProcessVerification.totalPowerShellCpuMs > WINDOWS_PROCESS_VERIFY_MAX_CPU_MS * WINDOWS_PROCESS_VERIFY_MAX_POWERSHELL_SPAWNS) throw new Error("Windows update cleanup exceeded its cumulative PowerShell CPU budget."); if (windowsProcessVerification.maximumPowerShellCpuMs > WINDOWS_PROCESS_VERIFY_MAX_CPU_MS) throw new Error("Windows update cleanup exceeded its per-process PowerShell CPU budget."); if (windowsProcessVerification.maximumPowerShellRssBytes > WINDOWS_PROCESS_VERIFY_MAX_RSS_BYTES) throw new Error("Windows update cleanup exceeded its PowerShell peak RSS budget."); };
|
|
22
|
+
const writeWindowsVerificationEvidence = (outcome, child = null) => { const evidencePath = String(process.env.LIVEDESK_CLIENT_UPDATE_PROCESS_EVIDENCE_PATH || "").trim(); if (!evidencePath) return; try { const evidence = { outcome, platform: process.platform, budgets: { maximumCimQueries: WINDOWS_PROCESS_VERIFY_MAX_CIM_QUERIES, maximumPowerShellSpawns: WINDOWS_PROCESS_VERIFY_MAX_POWERSHELL_SPAWNS, maximumCimDurationMs: WINDOWS_PROCESS_VERIFY_MAX_TOTAL_CIM_MS, maximumPowerShellCpuMs: WINDOWS_PROCESS_VERIFY_MAX_CPU_MS, maximumPowerShellRssBytes: WINDOWS_PROCESS_VERIFY_MAX_RSS_BYTES, maximumTotalPowerShellCpuMs: WINDOWS_PROCESS_VERIFY_MAX_CPU_MS * WINDOWS_PROCESS_VERIFY_MAX_POWERSHELL_SPAWNS, scope: "whole-cleanup-operation" }, ...windowsProcessVerification, terminalSnapshotPromise: Boolean(child?.livedeskWindowsSnapshotPromise), terminalDrainPromise: Boolean(child?.livedeskWindowsDrainPromise), terminalTrackingTimer: false }; const temporary = evidencePath + "." + process.pid + ".tmp"; fs.mkdirSync(path.dirname(evidencePath), { recursive: true }); fs.writeFileSync(temporary, JSON.stringify(evidence, null, 2), { encoding: "utf8", mode: 0o600 }); fs.renameSync(temporary, evidencePath); } catch {} };
|
|
23
|
+
const encodePowerShellCommand = value => Buffer.from(String(value || ""), "utf16le").toString("base64");
|
|
24
|
+
const spawnCapturedWindowsPowerShell = command => { const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encodePowerShellCommand(command)], { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }); windowsProcessVerification.powerShellSpawnCount += 1; windowsProcessVerification.activePowerShellChildren += 1; windowsProcessVerification.maximumConcurrentPowerShellChildren = Math.max(windowsProcessVerification.maximumConcurrentPowerShellChildren, windowsProcessVerification.activePowerShellChildren); let stdoutBytes = 0; let stderrBytes = 0; let stdout = ""; let stderr = ""; let outputOverflow = false; const append = (chunk, isError) => { const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); const previous = isError ? stderrBytes : stdoutBytes; const remaining = Math.max(0, WINDOWS_PROCESS_VERIFY_MAX_OUTPUT_BYTES - previous); if (buffer.length > remaining) outputOverflow = true; const retained = remaining > 0 ? buffer.subarray(0, remaining).toString("utf8") : ""; if (isError) { stderrBytes += buffer.length; stderr += retained; } else { stdoutBytes += buffer.length; stdout += retained; } }; child.stdout?.on("data", chunk => append(chunk, false)); child.stderr?.on("data", chunk => append(chunk, true)); const completion = new Promise(resolve => { let settled = false; const finish = (code, signal, error) => { if (settled) return; settled = true; windowsProcessVerification.activePowerShellChildren -= 1; resolve({ code, signal, error, stdout, stderr, outputOverflow }); }; child.once("error", error => finish(null, null, error)); child.once("close", (code, signal) => finish(code, signal, null)); }); return { child, completion }; };
|
|
25
|
+
const runWindowsPowerShell = async (script, kind = "control") => { if (windowsProcessVerification.powerShellSpawnCount + 1 > WINDOWS_PROCESS_VERIFY_MAX_POWERSHELL_SPAWNS) throw new Error("Windows update cleanup exhausted its measured PowerShell spawn budget."); if (kind === "cim" && windowsProcessVerification.cimQueryCount >= WINDOWS_PROCESS_VERIFY_MAX_CIM_QUERIES) throw new Error("Windows update cleanup exhausted its CIM query budget."); const remainingCimDurationMs = WINDOWS_PROCESS_VERIFY_MAX_TOTAL_CIM_MS - windowsProcessVerification.cimTotalDurationMs; if (kind === "cim" && remainingCimDurationMs <= 0) throw new Error("Windows update cleanup exhausted its cumulative CIM duration budget."); const timeoutMs = kind === "cim" ? Math.max(1, Math.min(WINDOWS_PROCESS_VERIFY_TIMEOUT_MS, Math.ceil(remainingCimDurationMs))) : WINDOWS_PROCESS_VERIFY_TIMEOUT_MS; const startedAtMs = Date.now(); if (kind === "cim") { windowsProcessVerification.cimQueryCount += 1; windowsProcessVerification.activeCimQueries += 1; } const action = Buffer.from(String(script || ""), "utf16le").toString("base64"); const wrapper = `$ErrorActionPreference = "Stop"; $result = [ordered]@{ EvidenceAvailable = $false; ProcessId = [int]$PID; StartOrder = ""; CpuMs = $null; PeakRssBytes = $null; Ok = $false; Output = ""; ActionError = "" }; $self = [Diagnostics.Process]::GetCurrentProcess(); try { $rawStartTicks = [long]$self.StartTime.ToUniversalTime().Ticks; $result.StartOrder = [string]($rawStartTicks - ($rawStartTicks % 10000)); $actionText = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String("${action}")); $actionOutput = & ([ScriptBlock]::Create($actionText)) | Out-String; $result.Output = [string]$actionOutput.TrimEnd(); $result.Ok = $true } catch { $result.ActionError = $_.Exception.Message } finally { try { $self.Refresh(); $result.CpuMs = [double]$self.TotalProcessorTime.TotalMilliseconds; $result.PeakRssBytes = [double]$self.PeakWorkingSet64; $result.EvidenceAvailable = $true } catch { if (-not $result.ActionError) { $result.ActionError = $_.Exception.Message } }; $result | ConvertTo-Json -Compress }`; let owner = null; let watchdog = null; let watchdogExpired = false; let completion = null; try { owner = spawnCapturedWindowsPowerShell(wrapper); windowsProcessVerification.activeVerificationWatchdogTimers += 1; watchdog = setTimeout(() => { watchdogExpired = true; try { owner?.child.kill("SIGKILL"); } catch {} }, timeoutMs); completion = await owner.completion; } finally { if (watchdog) { clearTimeout(watchdog); watchdog = null; windowsProcessVerification.activeVerificationWatchdogTimers -= 1; } if (owner?.child.exitCode === null && owner?.child.signalCode === null) { try { owner.child.kill("SIGKILL"); } catch {} } if (owner) await owner.completion; if (kind === "cim") { windowsProcessVerification.activeCimQueries -= 1; const elapsedCimMs = Math.max(0, Date.now() - startedAtMs); windowsProcessVerification.cimTotalDurationMs += elapsedCimMs; } } let envelope = null; try { envelope = JSON.parse(String(completion?.stdout || "").trim()); } catch {} const cpuMs = Number(envelope?.CpuMs); const peakRssBytes = Number(envelope?.PeakRssBytes); const exactResourceEvidence = envelope?.EvidenceAvailable === true && Number(envelope?.ProcessId) === Number(owner?.child.pid) && /^\d+$/.test(String(envelope?.StartOrder || "")) && Number.isFinite(cpuMs) && cpuMs >= 0 && Number.isFinite(peakRssBytes) && peakRssBytes > 0; if (!exactResourceEvidence) { windowsProcessVerification.powerShellResourceEvidenceMissingCount += 1; throw new Error("Windows PowerShell returned unknown exact-identity CPU/RSS evidence."); } windowsProcessVerification.powerShellResourceEvidenceCount += 1; windowsProcessVerification.totalPowerShellCpuMs += cpuMs; windowsProcessVerification.maximumPowerShellCpuMs = Math.max(windowsProcessVerification.maximumPowerShellCpuMs, cpuMs); windowsProcessVerification.maximumPowerShellRssBytes = Math.max(windowsProcessVerification.maximumPowerShellRssBytes, peakRssBytes); assertWindowsVerificationBudget(); if (watchdogExpired) throw new Error("Windows PowerShell child exceeded its exact timeout."); if (completion?.outputOverflow) throw new Error("Windows PowerShell output exceeded its bounded buffer."); if (completion?.error || Number(completion?.code) !== 0) throw new Error(String(completion?.stderr || completion?.error?.message || "PowerShell failed without a complete resource envelope.").trim()); if (envelope.Ok !== true || envelope.ActionError) throw new Error(String(envelope.ActionError || "PowerShell action failed.").trim()); return String(envelope.Output || "").trim(); };
|
|
26
|
+
const queryWindowsProcessTable = async () => { const stdout = await runWindowsPowerShell(`$ErrorActionPreference = "Stop"; $watch = [Diagnostics.Stopwatch]::StartNew(); $records = @(Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,CreationDate | ForEach-Object { if ($_.CreationDate) { $rawTicks = [long]$_.CreationDate.ToUniversalTime().Ticks; [pscustomobject]@{ ProcessId = [int]$_.ProcessId; ParentProcessId = [int]$_.ParentProcessId; CreationUtcTicks = [string]($rawTicks - ($rawTicks % 10000)) } } }); $watch.Stop(); [pscustomobject]@{ Records = $records; CimDurationMs = [double]$watch.Elapsed.TotalMilliseconds } | ConvertTo-Json -Compress -Depth 4`, "cim"); if (!stdout) throw new Error("Windows process snapshot returned no data"); const parsed = JSON.parse(stdout); const cimDurationMs = Number(parsed?.CimDurationMs); if (!Number.isFinite(cimDurationMs) || cimDurationMs < 0) throw new Error("Windows process snapshot omitted bounded CIM evidence."); windowsProcessVerification.maximumCimProviderDurationMs = Math.max(windowsProcessVerification.maximumCimProviderDurationMs, cimDurationMs); assertWindowsVerificationBudget(); 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)); };
|
|
27
|
+
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; };
|
|
28
|
+
const sameWindowsIdentity = (left, right) => Number(left?.pid || 0) === Number(right?.pid || 0) && String(left?.startOrder || "") === String(right?.startOrder || "");
|
|
29
|
+
const windowsTicksAtMilliseconds = value => BigInt(Math.trunc(Number(value) || Date.now())) * 10000n + 621355968000000000n;
|
|
30
|
+
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); };
|
|
31
|
+
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; };
|
|
32
|
+
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; };
|
|
33
|
+
const refreshTrackedWindowsTree = async (child, state = readState()) => { const snapshot = await queryTrackedWindowsProcessTable(child); const lowerBound = windowsTicksAtMilliseconds(Number(child.livedeskWindowsSpawnedAtMs || Date.now()) - 2000); const upperBound = child.livedeskWindowsRootLifetimeEndTicks ? BigInt(child.livedeskWindowsRootLifetimeEndTicks) : null; const currentByPid = new Map(snapshot.map(item => [item.pid, item])); const seedPids = new Set([Number(child.pid || 0), Number(state?.supervisorPid || 0)].filter(pid => Number.isInteger(pid) && pid > 1)); for (const seedPid of seedPids) { const seed = currentByPid.get(seedPid); if (!seed) continue; let seedStart; try { seedStart = BigInt(seed.startOrder); } catch { continue; } if (seedStart < lowerBound || (upperBound && seedStart > upperBound)) continue; mergeExactWindowsTree(child, seed, snapshot); } mergeRootAbsentWindowsTree(child, Number(child.pid || 0), snapshot); return snapshot; };
|
|
34
|
+
const stopExactWindowsRecords = async records => { if (!Array.isArray(records) || records.length === 0) return false; 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-Process -Id $targetPid -ErrorAction SilentlyContinue | Select-Object -First 1; if (-not $target) { continue }; $rawTicks = [long]$target.StartTime.ToUniversalTime().Ticks; $targetStartTicks = [string]($rawTicks - ($rawTicks % 10000)); if ($targetStartTicks -ne $expectedStartTicks) { continue }; Stop-Process -Id $targetPid -Force -ErrorAction Stop; try { [void]$target.WaitForExit(750) } catch {} } catch { $failures.Add("pid=$targetPid $($_.Exception.Message)") } }; $verifiedAbsent = 0; foreach ($item in $targets) { $targetPid = [int]$item.pid; $expectedStartTicks = [string]$item.startOrder; try { $current = Get-Process -Id $targetPid -ErrorAction SilentlyContinue | Select-Object -First 1; if ($current) { $rawTicks = [long]$current.StartTime.ToUniversalTime().Ticks; $currentStartTicks = [string]($rawTicks - ($rawTicks % 10000)); if ($currentStartTicks -eq $expectedStartTicks) { $failures.Add("pid=$targetPid exact identity survived Stop-Process"); continue } }; $verifiedAbsent += 1 } catch { $failures.Add("pid=$targetPid verification failed: $($_.Exception.Message)") } }; if ($failures.Count -gt 0) { throw ($failures -join "; ") }; [pscustomobject]@{ TerminalIdentityProof = $true; VerifiedAbsent = $verifiedAbsent; TargetCount = $targets.Count } | ConvertTo-Json -Compress`; const stdout = await runWindowsPowerShell(script, "control"); const proof = JSON.parse(stdout || "{}"); if (proof?.TerminalIdentityProof !== true || Number(proof?.VerifiedAbsent) !== records.length || Number(proof?.TargetCount) !== records.length) throw new Error("Windows exact stop omitted terminal identity proof."); return true; };
|
|
35
|
+
const trackWindowsChild = child => { if (process.platform !== "win32" || Number(child?.pid || 0) <= 1 || child.livedeskWindowsTrackingInitialized) return; child.livedeskWindowsTrackingInitialized = true; 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)); }); };
|
|
36
|
+
const stopWindowsTracking = child => { child.livedeskWindowsTrackingStopped = true; child.livedeskWindowsSnapshotPromise = null; };
|
|
37
|
+
const terminateExactWindowsTrackedTree = child => { if (child.livedeskWindowsDrainPromise) return child.livedeskWindowsDrainPromise; const pending = (async () => { let emptyPasses = 0; let lastError = null; for (let attempt = 1; attempt <= WINDOWS_PROCESS_VERIFY_MAX_CIM_QUERIES; attempt += 1) { try { const snapshot = await refreshTrackedWindowsTree(child, readState()); 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 (Number(process.env.LIVEDESK_TEST_FORCE_CLEANUP_PROOF_FAILURE_ONCE || 0) > 0 && windowsProcessVerification.forcedCleanupProofFailureCount === 0 && remaining.length > 0) { windowsProcessVerification.forcedCleanupProofFailureCount = 1; throw new Error("Simulated exact cleanup proof failure."); } if (remaining.length === 0) { emptyPasses += 1; } else { emptyPasses = await stopExactWindowsRecords(remaining) ? 1 : 0; } if (emptyPasses >= 2) { stopWindowsTracking(child); assertWindowsVerificationBudget(); windowsProcessVerification.terminalZeroProved = true; return; } child.livedeskWindowsTrackingError = null; lastError = null; } catch (error) { if (error?.message === "Simulated exact cleanup proof failure.") throw error; emptyPasses = 0; lastError = error; child.livedeskWindowsTrackingError = error; } if (attempt < WINDOWS_PROCESS_VERIFY_MAX_CIM_QUERIES) await sleep(150 * attempt); } stopWindowsTracking(child); throw lastError || new Error("Windows update cleanup did not reach two exact terminal identity proofs."); })(); child.livedeskWindowsDrainPromise = pending; const clear = () => { if (child.livedeskWindowsDrainPromise === pending) child.livedeskWindowsDrainPromise = null; }; pending.then(clear, clear); return pending; };
|
|
38
|
+
const stateLockPath = statePath + ".lock"; const lockWaitBuffer = new Int32Array(new SharedArrayBuffer(4));
|
|
39
|
+
const waitForStateLock = ms => Atomics.wait(lockWaitBuffer, 0, 0, Math.max(1, ms));
|
|
40
|
+
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; } };
|
|
41
|
+
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 {} } };
|
|
42
|
+
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; };
|
|
43
|
+
const writeCleanupBlocked = error => { 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, cleanupBlocked: true, cleanupTerminalZero: false, cleanupBlockedExactIdentities: windowsProcessVerification.blockedExactIdentities, error: String(error?.message || error).slice(0, 4000), failedAt: now, updatedAt: now }; const temporary = statePath + "." + process.pid + ".cleanup-blocked.tmp"; fs.writeFileSync(temporary, JSON.stringify(next, null, 2), { encoding: "utf8", mode: 0o600 }); fs.renameSync(temporary, statePath); }); } catch {} process.stderr.write("LiveDesk update cleanup blocked: " + (error?.message || error) + "\n"); process.exitCode = 1; };
|
|
44
|
+
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"; });
|
|
45
|
+
const terminateTree = async child => { const pid = Number(child?.pid || 0); if (pid <= 1) return; if (process.platform === "win32") { await terminateExactWindowsTrackedTree(child); if (windowsProcessVerification.activePowerShellChildren !== 0 || windowsProcessVerification.activeCimQueries !== 0) throw new Error("Windows update cleanup retained an active verification child."); 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 {} } } };
|
|
46
|
+
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); });
|
|
47
|
+
const stopDirectOwnedChild = async child => { const alreadyExited = child.exitCode !== null || child.signalCode !== null; windowsProcessVerification.directChildKillAttempted = !alreadyExited; if (!alreadyExited) { try { child.kill("SIGKILL"); } catch {} } const exited = child.exitCode !== null || child.signalCode !== null ? true : await new Promise(resolve => { let settled = false; const finish = value => { if (settled) return; settled = true; clearTimeout(deadline); child.removeListener("close", onClose); resolve(value); }; const onClose = () => finish(true); const deadline = setTimeout(() => finish(false), 750); child.once("close", onClose); }); for (const stream of [child.stdin, child.stdout, child.stderr]) { try { stream?.destroy?.(); } catch {} } windowsProcessVerification.directChildExitProved = exited || child.exitCode !== null || child.signalCode !== null; return windowsProcessVerification.directChildExitProved; };
|
|
48
|
+
const monitorPreflight = child => { let settled = false; let draining = false; let drainPromise = null; let stageWatcher = null; let stageFallback = null; let timeout = null; let inspectScheduled = false; const releaseObservation = () => { if (stageFallback) { clearInterval(stageFallback); stageFallback = null; windowsProcessVerification.activeStateFallbackTimers -= 1; } if (timeout) { clearTimeout(timeout); timeout = null; } if (stageWatcher) { try { stageWatcher.close(); } catch {} stageWatcher = null; windowsProcessVerification.activeStateWatchers -= 1; } }; const finish = (error, preserveFailure = false) => { if (settled) return; settled = true; releaseObservation(); stopWindowsTracking(child); child.unref(); windowsProcessVerification.failClosedOwnershipHeld = false; windowsProcessVerification.failClosedOwnershipTimerCount = 0; writeWindowsVerificationEvidence(error ? "error" : (preserveFailure ? "failed" : "preflight-ready"), child); if (preserveFailure) process.exitCode = 1; else if (error) writeFailure(error); }; const finishCleanupBlocked = async cleanupError => { if (settled) return; settled = true; draining = false; releaseObservation(); child.livedeskWindowsDrainPromise = null; drainPromise = null; windowsProcessVerification.cleanupBlockedCount += 1; windowsProcessVerification.failClosedOwnershipHeld = false; windowsProcessVerification.failClosedOwnershipTimerCount = 0; windowsProcessVerification.terminalZeroProved = false; windowsProcessVerification.blockedExactIdentities = [...(child.livedeskWindowsTrackedRecords?.values?.() || [])].map(record => ({ pid: Number(record.pid || 0), startOrder: String(record.startOrder || ""), depth: Number(record.depth || 0) })).filter(record => record.pid > 1 && /^\d+$/.test(record.startOrder)); windowsProcessVerification.blockedExactIdentityCount = windowsProcessVerification.blockedExactIdentities.length; await stopDirectOwnedChild(child); stopWindowsTracking(child); child.unref(); writeWindowsVerificationEvidence("cleanup-blocked", child); writeCleanupBlocked(cleanupError); }; const drainAndFinish = (error, preserveFailure = false) => { if (settled) return Promise.resolve(); draining = true; if (!drainPromise) drainPromise = terminateTree(child).then(() => { windowsProcessVerification.cleanupCompletedCount += 1; finish(error, preserveFailure); }).catch(async cleanupError => { windowsProcessVerification.cleanupFailureCount += 1; child.livedeskWindowsDrainPromise = null; drainPromise = null; await finishCleanupBlocked(cleanupError); }); return drainPromise; }; const inspect = () => { if (draining || settled) return; windowsProcessVerification.stateReadCount += 1; const state = readState(); if (state.operationId !== operationId) return; const committedOwner = Number(state.starterPid || 0) === process.pid && Number(state.supervisorPid || 0) > 1; if ((state.stage === "preflight-ready" || state.stage === "waiting-for-shutdown") && committedOwner) finish(); else if (state.stage === "failed") void drainAndFinish(null, true); }; const scheduleInspect = () => { if (inspectScheduled || draining || settled) return; inspectScheduled = true; setImmediate(() => { inspectScheduled = false; inspect(); }); }; 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); } }; try { stageWatcher = fs.watch(path.dirname(statePath), { persistent: false }, (_event, filename) => { if (filename && String(filename) !== path.basename(statePath)) return; windowsProcessVerification.stateWatchEventCount += 1; scheduleInspect(); }); windowsProcessVerification.activeStateWatchers += 1; stageWatcher.once("error", error => { if (settled || draining) return; void drainAndFinish(error); }); } catch {} stageFallback = setInterval(inspect, 2000); windowsProcessVerification.activeStateFallbackTimers += 1; stageFallback.unref?.(); const timeoutMs = Math.max(1, Math.min(Math.max(1000, Number(process.env.LIVEDESK_CLIENT_UPDATE_HANDOFF_TIMEOUT_MS || 140000)), updateDeadlineEpochMs - Date.now())); 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(); };
|
|
49
|
+
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); } };
|
|
50
|
+
if (!operationId || !versionPattern.test(targetProductVersion) || deadlineExpired()) { writeFailure(new Error("Invalid or expired LiveDesk exact-package update handoff."), true); } else { try { prepareNeutralCwd(); writeHandoffStarted();
|
|
51
|
+
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";
|
|
52
|
+
const nodeDir = path.dirname(process.execPath);
|
|
53
|
+
const npxCli = [env.LIVEDESK_NPX_CLI_PATH, env.npm_execpath ? path.join(path.dirname(env.npm_execpath), "npx-cli.js") : "", env.LIVEDESK_NPX_EXECUTABLE ? path.join(path.dirname(env.LIVEDESK_NPX_EXECUTABLE), "node_modules", "npm", "bin", "npx-cli.js") : "", path.join(nodeDir, "node_modules", "npm", "bin", "npx-cli.js")].find(value => value && fs.existsSync(value));
|
|
54
|
+
const npx = env.LIVEDESK_NPX_EXECUTABLE || (process.platform === "win32" ? "npx.cmd" : "npx");
|
|
55
|
+
const args = ["-y", "--prefer-online", "--prefix", neutralCwd, "--workspaces=false", "livedesk@" + targetProductVersion, "--internal-legacy-client-update"];
|
|
56
|
+
const quoteCmd = value => "\"" + String(value).replaceAll("%", "%%").replaceAll("\"", "\"\"") + "\"";
|
|
57
|
+
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 };
|
|
58
|
+
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); } }
|
|
59
|
+
} catch (error) { writeFailure(error, deadlineExpired()); } }
|
package/bin/livedesk-client.js
CHANGED
|
@@ -50,6 +50,7 @@ const DISCOVERY_RETRY_JITTER_RATIO = 0.2;
|
|
|
50
50
|
const EXIT_INVALID_PAIR_TOKEN = 23;
|
|
51
51
|
const EXIT_CLIENT_UPDATE = 42;
|
|
52
52
|
const SESSION_REFRESH_SKEW_SECONDS = 60;
|
|
53
|
+
const SUPABASE_REQUEST_TIMEOUT_MS = 8_000;
|
|
53
54
|
const HUB_TARGET_CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
54
55
|
const HUB_TARGET_CACHE_FUTURE_SKEW_MS = 5 * 60 * 1000;
|
|
55
56
|
const WINDOWS_STARTUP_SCRIPT_NAME = 'LiveDesk Desktop.vbs';
|
|
@@ -1077,10 +1078,52 @@ function clearSavedSession() {
|
|
|
1077
1078
|
rmSync(UNIFIED_CLIENT_AUTH_PATH, { force: true });
|
|
1078
1079
|
}
|
|
1079
1080
|
|
|
1081
|
+
export async function fetchSupabaseWithDeadline(
|
|
1082
|
+
input,
|
|
1083
|
+
init = {},
|
|
1084
|
+
timeoutMs = SUPABASE_REQUEST_TIMEOUT_MS
|
|
1085
|
+
) {
|
|
1086
|
+
const boundedTimeoutMs = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0
|
|
1087
|
+
? Math.min(60_000, Math.max(1, Math.round(Number(timeoutMs))))
|
|
1088
|
+
: SUPABASE_REQUEST_TIMEOUT_MS;
|
|
1089
|
+
const controller = new AbortController();
|
|
1090
|
+
const callerSignal = init?.signal;
|
|
1091
|
+
const forwardCallerAbort = () => {
|
|
1092
|
+
if (!controller.signal.aborted) {
|
|
1093
|
+
controller.abort(callerSignal?.reason);
|
|
1094
|
+
}
|
|
1095
|
+
};
|
|
1096
|
+
if (callerSignal?.aborted) {
|
|
1097
|
+
forwardCallerAbort();
|
|
1098
|
+
} else {
|
|
1099
|
+
callerSignal?.addEventListener?.('abort', forwardCallerAbort, { once: true });
|
|
1100
|
+
}
|
|
1101
|
+
const timer = setTimeout(() => {
|
|
1102
|
+
if (!controller.signal.aborted) {
|
|
1103
|
+
controller.abort(new Error('supabase-request-timeout'));
|
|
1104
|
+
}
|
|
1105
|
+
}, boundedTimeoutMs);
|
|
1106
|
+
timer.unref?.();
|
|
1107
|
+
try {
|
|
1108
|
+
return await fetch(input, { ...init, signal: controller.signal });
|
|
1109
|
+
} catch (error) {
|
|
1110
|
+
if (controller.signal.aborted && !callerSignal?.aborted) {
|
|
1111
|
+
throw new Error('supabase-request-timeout', { cause: error });
|
|
1112
|
+
}
|
|
1113
|
+
throw error;
|
|
1114
|
+
} finally {
|
|
1115
|
+
clearTimeout(timer);
|
|
1116
|
+
callerSignal?.removeEventListener?.('abort', forwardCallerAbort);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1080
1120
|
async function createSupabaseClient() {
|
|
1081
|
-
const { createClient } = await import('@supabase/supabase-js');
|
|
1082
|
-
return createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
|
|
1083
|
-
|
|
1121
|
+
const { createClient } = await import('@supabase/supabase-js');
|
|
1122
|
+
return createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
|
|
1123
|
+
global: {
|
|
1124
|
+
fetch: fetchSupabaseWithDeadline
|
|
1125
|
+
},
|
|
1126
|
+
auth: {
|
|
1084
1127
|
autoRefreshToken: false,
|
|
1085
1128
|
persistSession: true,
|
|
1086
1129
|
detectSessionInUrl: false,
|
|
@@ -3291,8 +3334,14 @@ async function startConnectionChoiceServer(supabase, options = {}) {
|
|
|
3291
3334
|
};
|
|
3292
3335
|
}
|
|
3293
3336
|
|
|
3294
|
-
async function chooseClientConnection(supabase, options = {}) {
|
|
3295
|
-
|
|
3337
|
+
async function chooseClientConnection(supabase, options = {}) {
|
|
3338
|
+
const getSupabase = async () => {
|
|
3339
|
+
const resolved = typeof supabase === 'function'
|
|
3340
|
+
? await supabase()
|
|
3341
|
+
: await supabase;
|
|
3342
|
+
return resolved;
|
|
3343
|
+
};
|
|
3344
|
+
if (isTruthy(process.env.LIVEDESK_UNIFIED_RUNTIME)) {
|
|
3296
3345
|
const connectionPage = createClientRuntimeServer({
|
|
3297
3346
|
host: process.env.LIVEDESK_CLIENT_RUNTIME_HOST || '127.0.0.1',
|
|
3298
3347
|
port: options.authPort || DEFAULT_AUTH_CALLBACK_PORT,
|
|
@@ -3304,11 +3353,13 @@ async function chooseClientConnection(supabase, options = {}) {
|
|
|
3304
3353
|
assignedHubId: process.env.LIVEDESK_ASSIGNED_HUB_ID,
|
|
3305
3354
|
roleVersion: process.env.LIVEDESK_ROLE_VERSION,
|
|
3306
3355
|
savedSession: options.savedSession,
|
|
3356
|
+
loadSavedSession: options.loadSavedSession,
|
|
3307
3357
|
initialChoice: options.initialChoice,
|
|
3308
3358
|
initialChoiceMessage: options.initialChoiceMessage,
|
|
3309
3359
|
beginGoogleSignIn: async redirectTo => {
|
|
3310
|
-
|
|
3311
|
-
|
|
3360
|
+
const activeSupabase = await getSupabase();
|
|
3361
|
+
if (!activeSupabase?.auth) throw new Error('supabase-session-required');
|
|
3362
|
+
const { data, error } = await activeSupabase.auth.signInWithOAuth({
|
|
3312
3363
|
provider: 'google',
|
|
3313
3364
|
options: {
|
|
3314
3365
|
redirectTo,
|
|
@@ -3321,34 +3372,37 @@ async function chooseClientConnection(supabase, options = {}) {
|
|
|
3321
3372
|
return { url: data.url };
|
|
3322
3373
|
},
|
|
3323
3374
|
exchangeGoogleCode: async code => {
|
|
3324
|
-
|
|
3325
|
-
|
|
3375
|
+
const activeSupabase = await getSupabase();
|
|
3376
|
+
if (!activeSupabase?.auth) throw new Error('supabase-session-required');
|
|
3377
|
+
const { data, error } = await activeSupabase.auth.exchangeCodeForSession(code);
|
|
3326
3378
|
if (error) throw error;
|
|
3327
3379
|
if (!data?.session) throw new Error('google-session-missing');
|
|
3328
3380
|
if (!writeSavedSessionToFile(data.session)) throw new Error('refresh-token-required');
|
|
3329
3381
|
return data.session;
|
|
3330
3382
|
},
|
|
3331
|
-
resolvePin: pin => {
|
|
3332
|
-
|
|
3333
|
-
|
|
3383
|
+
resolvePin: async pin => {
|
|
3384
|
+
const activeSupabase = await getSupabase();
|
|
3385
|
+
if (!activeSupabase) throw new Error('supabase-session-required');
|
|
3386
|
+
return resolveManagerFromPin(activeSupabase, pin, {
|
|
3334
3387
|
allowRelayFallback: options.allowRelayFallback === true,
|
|
3335
3388
|
relayEndpoint: options.relayEndpoint
|
|
3336
3389
|
});
|
|
3337
3390
|
},
|
|
3338
3391
|
changeRole: async (role, snapshot) => {
|
|
3339
|
-
|
|
3392
|
+
const activeSupabase = await getSupabase();
|
|
3393
|
+
if (!activeSupabase) {
|
|
3340
3394
|
return { ok: false, error: 'supabase-session-required' };
|
|
3341
3395
|
}
|
|
3342
3396
|
const portPreflight = await preflightHubClientPort();
|
|
3343
3397
|
if (!portPreflight.ok) {
|
|
3344
3398
|
return hubClientPortPreflightError(portPreflight);
|
|
3345
3399
|
}
|
|
3346
|
-
const session = await refreshSessionIfNeeded(
|
|
3400
|
+
const session = await refreshSessionIfNeeded(activeSupabase);
|
|
3347
3401
|
if (!session?.access_token) {
|
|
3348
3402
|
return { ok: false, error: 'supabase-session-required' };
|
|
3349
3403
|
}
|
|
3350
3404
|
const expectedRoleVersion = Number(snapshot?.roleVersion || 0);
|
|
3351
|
-
const { data, error } = await
|
|
3405
|
+
const { data, error } = await activeSupabase.rpc('set_livedesk_device_role', {
|
|
3352
3406
|
p_device_id: options.deviceId,
|
|
3353
3407
|
p_role: role,
|
|
3354
3408
|
p_assigned_hub_id: null,
|
|
@@ -3412,6 +3466,7 @@ async function chooseClientConnection(supabase, options = {}) {
|
|
|
3412
3466
|
}
|
|
3413
3467
|
});
|
|
3414
3468
|
await connectionPage.start();
|
|
3469
|
+
options.onStarted?.(connectionPage);
|
|
3415
3470
|
if (options.openBrowser !== false) {
|
|
3416
3471
|
console.log('Opening LiveDesk Client page...');
|
|
3417
3472
|
openBrowser(connectionPage.url);
|
|
@@ -3980,7 +4035,7 @@ export async function waitForManagerFromSupabase(supabase, options = {}) {
|
|
|
3980
4035
|
}
|
|
3981
4036
|
}
|
|
3982
4037
|
|
|
3983
|
-
async function prepareLoginConnection(parsed, existingConnectionPage = null) {
|
|
4038
|
+
async function prepareLoginConnection(parsed, existingConnectionPage = null, getSupabaseClient = createSupabaseClient) {
|
|
3984
4039
|
if (parsed.logout) {
|
|
3985
4040
|
clearSavedSession();
|
|
3986
4041
|
}
|
|
@@ -3996,15 +4051,31 @@ async function prepareLoginConnection(parsed, existingConnectionPage = null) {
|
|
|
3996
4051
|
let connectionTransport = '';
|
|
3997
4052
|
let directReachable = false;
|
|
3998
4053
|
const allowRelayFallback = transportAllowsRelay(parsed.transport);
|
|
3999
|
-
|
|
4054
|
+
|
|
4000
4055
|
if (shouldLogin) {
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
|
|
4056
|
+
let supabase = null;
|
|
4057
|
+
try {
|
|
4058
|
+
supabase = await getSupabaseClient();
|
|
4059
|
+
} catch (error) {
|
|
4060
|
+
console.warn(`LiveDesk auth provider is unavailable: ${error?.message || error}`);
|
|
4061
|
+
existingConnectionPage?.update({
|
|
4062
|
+
lastError: 'auth-provider-unavailable',
|
|
4063
|
+
message: 'The local Client is ready. LiveDesk sign-in is temporarily unavailable; retry from this page.'
|
|
4064
|
+
});
|
|
4065
|
+
if (!existingConnectionPage) throw error;
|
|
4066
|
+
}
|
|
4067
|
+
const startupArgs = buildStartupClientArgs(parsed);
|
|
4068
|
+
let savedSession = null;
|
|
4069
|
+
if (supabase) {
|
|
4070
|
+
try {
|
|
4071
|
+
savedSession = await refreshSessionIfNeeded(supabase);
|
|
4072
|
+
} catch (err) {
|
|
4073
|
+
console.warn(`LiveDesk saved sign-in could not be refreshed: ${err?.message || err}`);
|
|
4074
|
+
existingConnectionPage?.update({
|
|
4075
|
+
lastError: 'auth-provider-delayed',
|
|
4076
|
+
message: 'The local Client is ready. Saved sign-in is delayed; retry sign-in from this page.'
|
|
4077
|
+
});
|
|
4078
|
+
}
|
|
4008
4079
|
}
|
|
4009
4080
|
const savedPin = readSavedPin();
|
|
4010
4081
|
const previousChoice = existingConnectionPage?.getLastChoice?.() || null;
|
|
@@ -4021,21 +4092,31 @@ async function prepareLoginConnection(parsed, existingConnectionPage = null) {
|
|
|
4021
4092
|
choice = previousChoice;
|
|
4022
4093
|
}
|
|
4023
4094
|
if (!choice) {
|
|
4024
|
-
choice =
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4095
|
+
choice = existingConnectionPage
|
|
4096
|
+
? await existingConnectionPage.waitForChoice
|
|
4097
|
+
: await chooseClientConnection(supabase, {
|
|
4098
|
+
authPort: parsed.authPort,
|
|
4099
|
+
autoGoogle: !savedSession?.access_token && !savedPin,
|
|
4100
|
+
deviceId: parsed.deviceId,
|
|
4101
|
+
engine: parsed.engine,
|
|
4102
|
+
slot: parsed.slot,
|
|
4103
|
+
startupArgs,
|
|
4104
|
+
savedSession,
|
|
4105
|
+
savedPin,
|
|
4106
|
+
allowRelayFallback,
|
|
4107
|
+
relayEndpoint: parsed.relay,
|
|
4108
|
+
openBrowser: parsed.openBrowserOnStart
|
|
4109
|
+
});
|
|
4110
|
+
}
|
|
4111
|
+
connectionPage = existingConnectionPage || choice.connectionPage || null;
|
|
4112
|
+
if (existingConnectionPage && choice) {
|
|
4113
|
+
existingConnectionPage.acceptChoice?.(
|
|
4114
|
+
choice,
|
|
4115
|
+
choice.type === 'google'
|
|
4116
|
+
? 'Saved sign-in found. Finding the LiveDesk Hub.'
|
|
4117
|
+
: 'Client credentials accepted. Finding the LiveDesk Hub.'
|
|
4118
|
+
);
|
|
4119
|
+
}
|
|
4039
4120
|
if (choice.type === 'pin') {
|
|
4040
4121
|
manager = choice.manager;
|
|
4041
4122
|
pair = choice.pair;
|
|
@@ -4148,27 +4229,35 @@ async function prepareLoginConnection(parsed, existingConnectionPage = null) {
|
|
|
4148
4229
|
// supervisor uses that API to prove the new launcher and Agent are stable
|
|
4149
4230
|
// before it commits the update.
|
|
4150
4231
|
if (!shouldLogin
|
|
4151
|
-
&& !existingConnectionPage
|
|
4152
4232
|
&& isTruthy(process.env.LIVEDESK_UNIFIED_RUNTIME)
|
|
4153
4233
|
&& manager
|
|
4154
4234
|
&& pair) {
|
|
4155
4235
|
const preservedSession = readSavedSessionFromFile();
|
|
4156
|
-
const
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4236
|
+
const initialChoice = {
|
|
4237
|
+
type: 'explicit',
|
|
4238
|
+
manager,
|
|
4239
|
+
pair,
|
|
4240
|
+
endpointCandidates: [manager],
|
|
4241
|
+
...(preservedSession?.access_token ? { session: preservedSession } : {})
|
|
4242
|
+
};
|
|
4243
|
+
if (existingConnectionPage) {
|
|
4244
|
+
existingConnectionPage.acceptChoice?.(
|
|
4245
|
+
initialChoice,
|
|
4246
|
+
`Using the existing LiveDesk Hub pairing at ${manager}.`
|
|
4247
|
+
);
|
|
4248
|
+
connectionPage = existingConnectionPage;
|
|
4249
|
+
} else {
|
|
4250
|
+
const explicitConnection = await chooseClientConnection(null, {
|
|
4251
|
+
authPort: parsed.authPort,
|
|
4252
|
+
deviceId: parsed.deviceId,
|
|
4253
|
+
engine: parsed.engine,
|
|
4254
|
+
savedSession: preservedSession,
|
|
4255
|
+
openBrowser: false,
|
|
4256
|
+
initialChoice,
|
|
4257
|
+
initialChoiceMessage: `Using the existing LiveDesk Hub pairing at ${manager}.`
|
|
4258
|
+
});
|
|
4259
|
+
connectionPage = explicitConnection.connectionPage || null;
|
|
4260
|
+
}
|
|
4172
4261
|
}
|
|
4173
4262
|
|
|
4174
4263
|
const slot = normalizeSlotNumber(parsed.slot);
|
|
@@ -4784,7 +4873,7 @@ export function requiresFastTransport(prepared = {}) {
|
|
|
4784
4873
|
|| prepared.relayExplicit === true;
|
|
4785
4874
|
}
|
|
4786
4875
|
|
|
4787
|
-
export async function runClientRuntime(argv = process.argv.slice(2)) {
|
|
4876
|
+
export async function runClientRuntime(argv = process.argv.slice(2), runtimeOptions = {}) {
|
|
4788
4877
|
startNetworkChangeMonitor();
|
|
4789
4878
|
if (isTruthy(process.env.LIVEDESK_DESKTOP_HOST)) {
|
|
4790
4879
|
unregisterWindowsStartup();
|
|
@@ -4823,13 +4912,61 @@ export async function runClientRuntime(argv = process.argv.slice(2)) {
|
|
|
4823
4912
|
console.log(`[LiveDesk Client] Linux video acceleration: ${accelerationLabel}`);
|
|
4824
4913
|
}
|
|
4825
4914
|
|
|
4915
|
+
let supabaseClientPromise = null;
|
|
4916
|
+
const createSupabaseClientForRuntime = typeof runtimeOptions.createSupabaseClient === 'function'
|
|
4917
|
+
? runtimeOptions.createSupabaseClient
|
|
4918
|
+
: createSupabaseClient;
|
|
4919
|
+
const getSupabaseClient = () => {
|
|
4920
|
+
if (!supabaseClientPromise) {
|
|
4921
|
+
const pending = Promise.resolve().then(() => createSupabaseClientForRuntime());
|
|
4922
|
+
supabaseClientPromise = pending;
|
|
4923
|
+
void pending.catch(() => {
|
|
4924
|
+
if (supabaseClientPromise === pending) {
|
|
4925
|
+
supabaseClientPromise = null;
|
|
4926
|
+
}
|
|
4927
|
+
});
|
|
4928
|
+
}
|
|
4929
|
+
return supabaseClientPromise;
|
|
4930
|
+
};
|
|
4826
4931
|
let connectionPage = null;
|
|
4932
|
+
if (isTruthy(process.env.LIVEDESK_UNIFIED_RUNTIME)) {
|
|
4933
|
+
let resolveStarted;
|
|
4934
|
+
let rejectStarted;
|
|
4935
|
+
const started = new Promise((resolve, reject) => {
|
|
4936
|
+
resolveStarted = resolve;
|
|
4937
|
+
rejectStarted = reject;
|
|
4938
|
+
});
|
|
4939
|
+
const startupChoice = chooseClientConnection(getSupabaseClient, {
|
|
4940
|
+
authPort: parsed.authPort,
|
|
4941
|
+
autoGoogle: false,
|
|
4942
|
+
deviceId: parsed.deviceId,
|
|
4943
|
+
engine: parsed.engine,
|
|
4944
|
+
slot: parsed.slot,
|
|
4945
|
+
startupArgs: buildStartupClientArgs(parsed),
|
|
4946
|
+
savedSession: null,
|
|
4947
|
+
loadSavedSession: false,
|
|
4948
|
+
savedPin: null,
|
|
4949
|
+
allowRelayFallback: transportAllowsRelay(parsed.transport),
|
|
4950
|
+
relayEndpoint: parsed.relay,
|
|
4951
|
+
openBrowser: parsed.openBrowserOnStart,
|
|
4952
|
+
onStarted: page => {
|
|
4953
|
+
connectionPage = page;
|
|
4954
|
+
resolveStarted(page);
|
|
4955
|
+
}
|
|
4956
|
+
});
|
|
4957
|
+
void startupChoice.catch(rejectStarted);
|
|
4958
|
+
connectionPage = await started;
|
|
4959
|
+
connectionPage.update({
|
|
4960
|
+
message: 'Checking saved sign-in and the current LiveDesk Hub.'
|
|
4961
|
+
});
|
|
4962
|
+
console.log(`[LiveDesk Client] Local status is ready at ${connectionPage.url}`);
|
|
4963
|
+
}
|
|
4827
4964
|
while (true) {
|
|
4828
4965
|
const savedSlot = readSavedDeviceSlot(parsed.deviceId);
|
|
4829
4966
|
if (savedSlot) {
|
|
4830
4967
|
parsed.slot = savedSlot;
|
|
4831
4968
|
}
|
|
4832
|
-
const prepared = await prepareLoginConnection(parsed, connectionPage);
|
|
4969
|
+
const prepared = await prepareLoginConnection(parsed, connectionPage, getSupabaseClient);
|
|
4833
4970
|
connectionPage = prepared.connectionPage || connectionPage;
|
|
4834
4971
|
prepared.connectionPage?.update({
|
|
4835
4972
|
manager: prepared.manager,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@livedesk/client",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.217",
|
|
4
4
|
"description": "LiveDesk local remote client",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,14 +10,14 @@
|
|
|
10
10
|
"livedesk-client-fast": "bin/livedesk-client-fast.js"
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
|
-
"bin/",
|
|
14
|
-
"src/",
|
|
15
|
-
"tests/",
|
|
16
|
-
"README.md",
|
|
13
|
+
"bin/",
|
|
14
|
+
"src/",
|
|
15
|
+
"tests/",
|
|
16
|
+
"README.md",
|
|
17
17
|
"THIRD_PARTY_NOTICES.md"
|
|
18
18
|
],
|
|
19
19
|
"scripts": {
|
|
20
|
-
"check": "node --check bin/client-version.js && node --check bin/livedesk-client.js && node --check bin/livedesk-client-node.js && node --check bin/livedesk-client-fast.js",
|
|
20
|
+
"check": "node --check bin/client-version.js && node --check bin/livedesk-client.js && node --check bin/livedesk-client-node.js && node --check bin/livedesk-client-update-bootstrap.cjs && node --check bin/livedesk-client-fast.js",
|
|
21
21
|
"test:version": "node --test tests/client-version.test.mjs",
|
|
22
22
|
"pack:dry": "npm pack --dry-run"
|
|
23
23
|
},
|
|
@@ -35,16 +35,16 @@
|
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
|
37
37
|
"ffmpeg-static": "^5.3.0",
|
|
38
|
-
"@livedesk/runtime-core": "0.1.
|
|
38
|
+
"@livedesk/runtime-core": "0.1.1",
|
|
39
39
|
"@supabase/supabase-js": "^2.110.0",
|
|
40
40
|
"node-screenshots": "^0.2.8",
|
|
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.422",
|
|
45
|
+
"@livedesk/fast-osx-arm64": "0.1.422",
|
|
46
|
+
"@livedesk/fast-osx-x64": "0.1.422",
|
|
47
|
+
"@livedesk/fast-win-x64": "0.1.422"
|
|
48
48
|
},
|
|
49
49
|
"publishConfig": {
|
|
50
50
|
"access": "public"
|