@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.
@@ -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()); } }
@@ -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
- auth: {
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
- if (isTruthy(process.env.LIVEDESK_UNIFIED_RUNTIME)) {
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
- if (!supabase?.auth) throw new Error('supabase-session-required');
3311
- const { data, error } = await supabase.auth.signInWithOAuth({
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
- if (!supabase?.auth) throw new Error('supabase-session-required');
3325
- const { data, error } = await supabase.auth.exchangeCodeForSession(code);
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
- if (!supabase) throw new Error('supabase-session-required');
3333
- return resolveManagerFromPin(supabase, pin, {
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
- if (!supabase) {
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(supabase);
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 supabase.rpc('set_livedesk_device_role', {
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
- const supabase = await createSupabaseClient();
4002
- const startupArgs = buildStartupClientArgs(parsed);
4003
- let savedSession = null;
4004
- try {
4005
- savedSession = await refreshSessionIfNeeded(supabase);
4006
- } catch (err) {
4007
- console.warn(`LiveDesk saved sign-in could not be refreshed: ${err?.message || err}`);
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 = await chooseClientConnection(supabase, {
4025
- authPort: parsed.authPort,
4026
- autoGoogle: !savedSession?.access_token && !savedPin,
4027
- deviceId: parsed.deviceId,
4028
- engine: parsed.engine,
4029
- slot: parsed.slot,
4030
- startupArgs,
4031
- savedSession,
4032
- savedPin,
4033
- allowRelayFallback,
4034
- relayEndpoint: parsed.relay,
4035
- openBrowser: parsed.openBrowserOnStart
4036
- });
4037
- }
4038
- connectionPage = existingConnectionPage || choice.connectionPage || null;
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 explicitConnection = await chooseClientConnection(null, {
4157
- authPort: parsed.authPort,
4158
- deviceId: parsed.deviceId,
4159
- engine: parsed.engine,
4160
- savedSession: preservedSession,
4161
- openBrowser: false,
4162
- initialChoice: {
4163
- type: 'explicit',
4164
- manager,
4165
- pair,
4166
- endpointCandidates: [manager],
4167
- ...(preservedSession?.access_token ? { session: preservedSession } : {})
4168
- },
4169
- initialChoiceMessage: `Using the existing LiveDesk Hub pairing at ${manager}.`
4170
- });
4171
- connectionPage = explicitConnection.connectionPage || null;
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.215",
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.0",
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.419",
45
- "@livedesk/fast-osx-arm64": "0.1.419",
46
- "@livedesk/fast-osx-x64": "0.1.419",
47
- "@livedesk/fast-win-x64": "0.1.419"
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"