@livedesk/hub 0.1.3 → 0.1.5

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,457 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ export const LIVE_DESK_UPDATE_COMMAND = 'livedesk.client-update';
4
+ // 0.1.143 contains the launcher-PID-aware updater. Older clients use the
5
+ // Hub-generated legacy command so they can still be upgraded safely.
6
+ export const LIVE_DESK_DEDICATED_CLIENT_UPDATE_MIN_VERSION = '0.1.143';
7
+ export const LIVE_DESK_UPDATE_TIMEOUT_MS = 180_000;
8
+ export const LIVE_DESK_UPDATE_CHECK_INTERVAL_MS = 60_000;
9
+
10
+ function cleanVersion(value) {
11
+ return String(value || '').trim().replace(/^v/i, '');
12
+ }
13
+
14
+ export function compareVersions(left, right) {
15
+ const a = cleanVersion(left).split(/[+-]/, 1)[0].split('.').map(part => Number(part) || 0);
16
+ const b = cleanVersion(right).split(/[+-]/, 1)[0].split('.').map(part => Number(part) || 0);
17
+ const length = Math.max(a.length, b.length, 3);
18
+ for (let index = 0; index < length; index += 1) {
19
+ const difference = (a[index] || 0) - (b[index] || 0);
20
+ if (difference !== 0) return difference;
21
+ }
22
+ return 0;
23
+ }
24
+
25
+ export function isVersionAtLeast(candidate, required) {
26
+ return !!cleanVersion(candidate) && !!cleanVersion(required) && compareVersions(candidate, required) >= 0;
27
+ }
28
+
29
+ function encodePowerShell(value) {
30
+ return Buffer.from(String(value || ''), 'utf16le').toString('base64');
31
+ }
32
+
33
+ function quotePowerShell(value) {
34
+ return `'${String(value ?? '').replaceAll("'", "''")}'`;
35
+ }
36
+
37
+ function buildWindowsLegacyUpdateCommand({ manager, pair, name, slot, targetVersion }) {
38
+ const script = [
39
+ '$ErrorActionPreference = "Stop"',
40
+ '$cursor = Get-CimInstance Win32_Process -Filter ("ProcessId={0}" -f $PID)',
41
+ '$launcherPid = 0',
42
+ '$configuredLauncherPid = 0',
43
+ 'if ([int]::TryParse([string]$env:LIVEDESK_CLIENT_PARENT_PID, [ref]$configuredLauncherPid) -and $configuredLauncherPid -gt 1) { try { Get-Process -Id $configuredLauncherPid -ErrorAction Stop | Out-Null; $launcherPid = $configuredLauncherPid } catch {} }',
44
+ 'if ($launcherPid -le 0) {',
45
+ ' for ($depth = 0; $depth -lt 12 -and $cursor; $depth++) {',
46
+ ' $parent = Get-CimInstance Win32_Process -Filter ("ProcessId={0}" -f [int]$cursor.ParentProcessId)',
47
+ ' if (-not $parent) { break }',
48
+ ' $commandLine = [string]$parent.CommandLine',
49
+ ' if ($commandLine -match "(?i)(livedesk-client\\.js|livedesk\\.js)") { $launcherPid = [int]$parent.ProcessId; break }',
50
+ ' $cursor = $parent',
51
+ ' }',
52
+ '}',
53
+ 'if ($launcherPid -le 0) { throw "LiveDesk client launcher process was not found." }',
54
+ `$env:LIVEDESK_CLIENT_MANAGER = ${quotePowerShell(manager)}`,
55
+ `$env:LIVEDESK_CLIENT_PAIR_TOKEN = ${quotePowerShell(pair)}`,
56
+ `$env:LIVEDESK_CLIENT_NAME = ${quotePowerShell(name)}`,
57
+ `$env:LIVEDESK_CLIENT_SLOT = ${quotePowerShell(slot || '')}`,
58
+ `$env:LIVEDESK_CLIENT_UPDATE_TARGET_VERSION = ${quotePowerShell(targetVersion)}`,
59
+ '$node = [string]$env:LIVEDESK_NODE_EXECUTABLE',
60
+ 'if ($node -and -not (Test-Path -LiteralPath $node -PathType Leaf)) { $node = "" }',
61
+ 'if (-not $node) { $node = (Get-Command node.exe -ErrorAction SilentlyContinue).Source }',
62
+ 'if (-not $node) { throw "LiveDesk node executable was not found." }',
63
+ `$env:LIVEDESK_UPDATE_BOOTSTRAP_BASE64 = ${quotePowerShell(Buffer.from(buildUnixClientUpdateBootstrapScript(), 'utf8').toString('base64'))}`,
64
+ '$env:LIVEDESK_UPDATE_WAIT_PID = [string]$launcherPid',
65
+ '$env:LIVEDESK_UPDATE_AGENT_PID = "0"',
66
+ '$bootstrapRunner = "eval(Buffer.from(process.env.LIVEDESK_UPDATE_BOOTSTRAP_BASE64, \'base64\').toString(\'utf8\'))"',
67
+ 'Start-Process -FilePath $node -ArgumentList @("-e", $bootstrapRunner) -WindowStyle Hidden',
68
+ 'Start-Sleep -Milliseconds 750',
69
+ 'Start-Process -FilePath "taskkill.exe" -ArgumentList @("/PID", [string]$launcherPid, "/T", "/F") -WindowStyle Hidden',
70
+ ''
71
+ ].join('\r\n');
72
+ return `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodePowerShell(script)}`;
73
+ }
74
+
75
+ function buildUnixClientUpdateBootstrapScript() {
76
+ return [
77
+ 'const { spawn } = require("node:child_process");',
78
+ 'const fs = require("node:fs");',
79
+ 'const path = require("node:path");',
80
+ 'const waitPid = Number(process.env.LIVEDESK_UPDATE_WAIT_PID || 0);',
81
+ 'const agentPid = Number(process.env.LIVEDESK_UPDATE_AGENT_PID || 0);',
82
+ 'const isAlive = pid => { try { process.kill(pid, 0); return true; } catch (error) { return error?.code !== "ESRCH"; } };',
83
+ 'const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));',
84
+ 'const waitForExit = async pid => { for (let attempt = 0; attempt < 360 && pid > 1; attempt += 1) { if (!isAlive(pid)) return; await sleep(500); } if (pid > 1 && isAlive(pid)) throw new Error("LiveDesk client process did not exit before update installation."); };',
85
+ 'const nodeDir = path.dirname(process.execPath);',
86
+ 'const npxCandidates = process.platform === "win32" ? [process.env.LIVEDESK_NPX_EXECUTABLE, path.join(nodeDir, "npx.cmd"), path.join(nodeDir, "npx.exe"), "npx.cmd"] : [process.env.LIVEDESK_NPX_EXECUTABLE, path.join(nodeDir, "npx"), "npx"];',
87
+ 'const npx = npxCandidates.find(candidate => !path.isAbsolute(candidate) || fs.existsSync(candidate)) || npxCandidates[npxCandidates.length - 1];',
88
+ 'const npmExecPath = String(process.env.npm_execpath || process.env.NPM_EXECPATH || "").trim();',
89
+ 'const npxCliCandidates = [process.env.LIVEDESK_NPX_CLI_PATH, npmExecPath ? path.join(path.dirname(npmExecPath), "npx-cli.js") : "", path.join(nodeDir, "node_modules", "npm", "bin", "npx-cli.js")].filter(Boolean);',
90
+ 'const npxCli = npxCliCandidates.find(candidate => fs.existsSync(candidate));',
91
+ 'const npxArgs = ["-y", "--prefer-online", "livedesk@latest", "--force-role", "client", "--no-login", "--no-open"];',
92
+ 'const command = npxCli ? process.execPath : (process.platform === "win32" ? (process.env.ComSpec || "cmd.exe") : npx);',
93
+ 'const commandArgs = npxCli ? [npxCli, ...npxArgs] : (process.platform === "win32" ? ["/d", "/s", "/c", "call \\"" + npx + "\\" " + npxArgs.join(" ")] : npxArgs);',
94
+ 'process.env.LIVEDESK_SKIP_BROWSER_OPEN = "1";',
95
+ '(async () => { await waitForExit(waitPid); await waitForExit(agentPid); const updater = spawn(command, commandArgs, { detached: true, stdio: "ignore", windowsHide: true, env: process.env }); updater.once("error", error => { console.error("LiveDesk updater failed to start: " + error.message); process.exitCode = 1; }); updater.once("spawn", () => updater.unref()); })().catch(error => { console.error("LiveDesk updater failed: " + error.message); process.exitCode = 1; });',
96
+ ''
97
+ ].join('\n');
98
+ }
99
+
100
+ function buildUnixLegacyUpdateCommand({ manager, pair, name, slot, targetVersion }) {
101
+ const script = [
102
+ 'const { execFileSync, spawn } = require("node:child_process");',
103
+ 'const fs = require("node:fs");',
104
+ 'const path = require("node:path");',
105
+ 'const parentOf = pid => { try { return Number(execFileSync("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8" }).trim()) || 0; } catch { return 0; } };',
106
+ 'const commandOf = pid => { try { return execFileSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf8" }); } catch { return ""; } };',
107
+ 'const isAlive = pid => { try { process.kill(pid, 0); return true; } catch { return false; } };',
108
+ 'const configuredLauncherPid = Number(process.env.LIVEDESK_CLIENT_PARENT_PID || 0);',
109
+ 'let launcherPid = Number.isInteger(configuredLauncherPid) && configuredLauncherPid > 1 && isAlive(configuredLauncherPid) ? configuredLauncherPid : 0;',
110
+ 'let agentPid = Number(process.env.LIVEDESK_CLIENT_AGENT_PID || 0);',
111
+ 'let cursor = parentOf(process.pid);',
112
+ 'for (let depth = 0; depth < 12 && cursor; depth++) { const command = commandOf(cursor); if (!agentPid && /livedesk-client-node\\.js|livedesk-client-fast|RemoteAgent\\.Fast/i.test(command)) agentPid = cursor; if (!launcherPid && /livedesk-client\\.js|livedesk\\.js/i.test(command)) launcherPid = cursor; cursor = parentOf(cursor); }',
113
+ 'if (launcherPid <= 1) throw new Error("LiveDesk client launcher process was not found.");',
114
+ 'const waitPid = launcherPid;',
115
+ `process.env.LIVEDESK_CLIENT_MANAGER = ${JSON.stringify(String(manager || ''))};`,
116
+ `process.env.LIVEDESK_CLIENT_PAIR_TOKEN = ${JSON.stringify(String(pair || ''))};`,
117
+ `process.env.LIVEDESK_CLIENT_NAME = ${JSON.stringify(String(name || ''))};`,
118
+ `process.env.LIVEDESK_CLIENT_SLOT = ${JSON.stringify(String(slot || ''))};`,
119
+ `process.env.LIVEDESK_CLIENT_UPDATE_TARGET_VERSION = ${JSON.stringify(String(targetVersion || ''))};`,
120
+ `const bootstrapScript = ${JSON.stringify(buildUnixClientUpdateBootstrapScript())};`,
121
+ 'const bootstrapEnv = { ...process.env, LIVEDESK_UPDATE_WAIT_PID: String(waitPid), LIVEDESK_UPDATE_AGENT_PID: String(agentPid) };',
122
+ 'const updater = spawn(process.execPath, ["-e", bootstrapScript], { detached: true, stdio: "ignore", env: bootstrapEnv });',
123
+ 'updater.once("error", error => { console.error("LiveDesk updater bootstrap failed to start: " + error.message); process.exitCode = 1; });',
124
+ 'updater.once("spawn", () => { updater.unref(); setTimeout(() => { try { process.kill(waitPid, "SIGTERM"); } catch {} if (agentPid > 1 && agentPid !== process.pid) setTimeout(() => { try { process.kill(agentPid, "SIGTERM"); } catch {} }, 500); }, 750); });',
125
+ ''
126
+ ].join('\n');
127
+ return [
128
+ 'if [ -n "$LIVEDESK_NODE_EXECUTABLE" ]; then node_bin="$LIVEDESK_NODE_EXECUTABLE"; else node_bin="$(ps -p "$LIVEDESK_CLIENT_PARENT_PID" -o comm= 2>/dev/null | sed "s/^ *//")"; if [ -z "$node_bin" ]; then node_bin="node"; fi; fi',
129
+ `"$node_bin" -e ${JSON.stringify(script)}`
130
+ ].join('\n');
131
+ }
132
+
133
+ function buildLegacyUpdateCommand(device, credentials, targetVersion) {
134
+ const payload = {
135
+ manager: credentials.manager,
136
+ pair: credentials.pairToken,
137
+ name: device.deviceName || device.hostname || '',
138
+ slot: device.slotNumber || '',
139
+ targetVersion
140
+ };
141
+ return ['win32', 'windows'].includes(String(device.platform || '').toLowerCase())
142
+ ? buildWindowsLegacyUpdateCommand(payload)
143
+ : buildUnixLegacyUpdateCommand(payload);
144
+ }
145
+
146
+ async function fetchLatestPackage(packageName, fetchImpl) {
147
+ const encodedName = packageName.startsWith('@') ? packageName.replace('/', '%2F') : packageName;
148
+ const response = await fetchImpl(`https://registry.npmjs.org/${encodedName}/latest`, {
149
+ headers: { Accept: 'application/json' },
150
+ signal: AbortSignal.timeout(12_000)
151
+ });
152
+ if (!response.ok) {
153
+ throw new Error(`npm registry returned HTTP ${response.status} for ${packageName}.`);
154
+ }
155
+ const payload = await response.json();
156
+ const version = cleanVersion(payload?.version);
157
+ if (!version) throw new Error(`npm registry returned no version for ${packageName}.`);
158
+ return { version, package: payload };
159
+ }
160
+
161
+ export async function fetchLatestLiveDeskRelease(fetchImpl = globalThis.fetch) {
162
+ if (typeof fetchImpl !== 'function') throw new Error('fetch is unavailable for LiveDesk update checks.');
163
+ const [manager, client] = await Promise.all([
164
+ fetchLatestPackage('livedesk', fetchImpl),
165
+ fetchLatestPackage('@livedesk/client', fetchImpl)
166
+ ]);
167
+ return {
168
+ latestManagerVersion: manager.version,
169
+ latestClientVersion: client.version,
170
+ managerPackage: manager.package,
171
+ clientPackage: client.package,
172
+ checkedAt: new Date().toISOString()
173
+ };
174
+ }
175
+
176
+ function statusForRun(run) {
177
+ if (!run) return null;
178
+ return {
179
+ operationId: run.operationId,
180
+ state: run.state,
181
+ startedAt: run.startedAt,
182
+ updatedAt: run.updatedAt,
183
+ targetCount: run.targets.length,
184
+ completedCount: run.targets.filter(target => target.state === 'completed').length,
185
+ waitingCount: run.targets.filter(target => target.state === 'waiting').length,
186
+ failedCount: run.targets.filter(target => target.state === 'failed').length,
187
+ error: run.error || '',
188
+ targets: run.targets.map(target => ({ ...target }))
189
+ };
190
+ }
191
+
192
+ function connectedDevices(remoteHub) {
193
+ return remoteHub.listDevices({ includeDataUrl: false })
194
+ .filter(device => device.connected === true && device.synthetic !== true)
195
+ .slice(0, 500);
196
+ }
197
+
198
+ export function createLiveDeskUpdateManager({
199
+ remoteHub,
200
+ currentManagerVersion,
201
+ currentClientVersion,
202
+ restartSupported = false,
203
+ requestHubRestart,
204
+ fetchImpl = globalThis.fetch,
205
+ now = () => Date.now()
206
+ }) {
207
+ let latestRelease = null;
208
+ let checkError = '';
209
+ let checkPromise = null;
210
+ let run = null;
211
+ let checkTimer = null;
212
+ let runTimer = null;
213
+
214
+ const touch = () => {
215
+ if (run) run.updatedAt = new Date(now()).toISOString();
216
+ };
217
+
218
+ const checkLatest = async () => {
219
+ if (checkPromise) return checkPromise;
220
+ checkPromise = fetchLatestLiveDeskRelease(fetchImpl)
221
+ .then(release => {
222
+ latestRelease = release;
223
+ checkError = '';
224
+ return release;
225
+ })
226
+ .catch(error => {
227
+ checkError = error instanceof Error ? error.message : String(error);
228
+ return null;
229
+ })
230
+ .finally(() => {
231
+ checkPromise = null;
232
+ });
233
+ return checkPromise;
234
+ };
235
+
236
+ const getStatus = () => {
237
+ const managerUpdateAvailable = !!latestRelease
238
+ && compareVersions(latestRelease.latestManagerVersion, currentManagerVersion) > 0;
239
+ const clientDevices = connectedDevices(remoteHub);
240
+ const outdatedClientDevices = latestRelease
241
+ ? clientDevices.filter(device => !isVersionAtLeast(device.agentVersion, latestRelease.latestClientVersion))
242
+ : [];
243
+ const clientPackageUpdateAvailable = !!latestRelease
244
+ && compareVersions(latestRelease.latestClientVersion, currentClientVersion) > 0;
245
+ const clientUpdateAvailable = clientPackageUpdateAvailable || outdatedClientDevices.length > 0;
246
+ const updateAvailable = managerUpdateAvailable || clientUpdateAvailable;
247
+ const activeRun = statusForRun(run);
248
+ return {
249
+ currentVersion: String(currentManagerVersion || ''),
250
+ currentClientVersion: String(currentClientVersion || ''),
251
+ latestVersion: latestRelease?.latestManagerVersion || '',
252
+ latestClientVersion: latestRelease?.latestClientVersion || '',
253
+ updateAvailable,
254
+ managerUpdateAvailable,
255
+ clientUpdateAvailable,
256
+ clientPackageUpdateAvailable,
257
+ outdatedClientCount: outdatedClientDevices.length,
258
+ checkedAt: latestRelease?.checkedAt || '',
259
+ checkError,
260
+ restartSupported: !!restartSupported,
261
+ canApply: updateAvailable && (!managerUpdateAvailable && !clientPackageUpdateAvailable || !!restartSupported),
262
+ ...(activeRun || { state: 'idle', operationId: '', targetCount: 0, completedCount: 0, waitingCount: 0, failedCount: 0, targets: [] })
263
+ };
264
+ };
265
+
266
+ const failRun = (message) => {
267
+ if (!run) return;
268
+ run.state = 'failed';
269
+ run.error = String(message || 'LiveDesk update failed.');
270
+ touch();
271
+ if (runTimer) {
272
+ clearInterval(runTimer);
273
+ runTimer = null;
274
+ }
275
+ };
276
+
277
+ const requestRestart = () => {
278
+ if (!restartSupported) {
279
+ failRun('Hub launcher restart is unavailable. Start LiveDesk through npx livedesk@latest.');
280
+ return false;
281
+ }
282
+ const result = requestHubRestart?.({
283
+ operationId: run?.operationId || '',
284
+ latestVersion: latestRelease?.latestManagerVersion || '',
285
+ latestClientVersion: latestRelease?.latestClientVersion || '',
286
+ // Current versions update every connected Client before the Hub exits.
287
+ // Older Hub versions did the reverse and set this flag themselves; the
288
+ // new server still supports that one-time migration path.
289
+ continueClientUpdate: false
290
+ });
291
+ if (result?.ok !== true) {
292
+ failRun(result?.error || 'Hub launcher restart request failed.');
293
+ return false;
294
+ }
295
+ if (run) {
296
+ run.state = 'hub-restart-requested';
297
+ touch();
298
+ }
299
+ return true;
300
+ };
301
+
302
+ const verifyTargets = () => {
303
+ if (!run || run.state !== 'waiting-for-clients') return;
304
+ const devices = new Map(connectedDevices(remoteHub).map(device => [String(device.deviceId), device]));
305
+ for (const target of run.targets) {
306
+ if (target.state === 'failed' || target.state === 'completed') continue;
307
+ const device = devices.get(target.deviceId);
308
+ if (device?.connected === true && isVersionAtLeast(device.agentVersion, run.latestClientVersion)) {
309
+ target.state = 'completed';
310
+ target.currentVersion = String(device.agentVersion || '');
311
+ target.completedAt = new Date(now()).toISOString();
312
+ } else {
313
+ target.state = 'waiting';
314
+ }
315
+ }
316
+ touch();
317
+ if (run.targets.every(target => target.state === 'completed')) {
318
+ if (runTimer) {
319
+ clearInterval(runTimer);
320
+ runTimer = null;
321
+ }
322
+ if (run.needsHubRestart) {
323
+ requestRestart();
324
+ } else {
325
+ run.state = 'clients-updated';
326
+ touch();
327
+ }
328
+ return;
329
+ }
330
+ if (now() - Date.parse(run.startedAt) >= LIVE_DESK_UPDATE_TIMEOUT_MS) {
331
+ failRun(`Timed out waiting for ${run.targets.filter(target => target.state !== 'completed').length} client(s) to reconnect with ${run.latestClientVersion}.`);
332
+ }
333
+ };
334
+
335
+ const dispatchTarget = (target, credentials) => {
336
+ const device = target.device;
337
+ const supportsDedicated = device.capabilities?.clientUpdate === true
338
+ && isVersionAtLeast(device.agentVersion, LIVE_DESK_DEDICATED_CLIENT_UPDATE_MIN_VERSION);
339
+ const payload = {
340
+ targetVersion: run.latestClientVersion,
341
+ managerVersion: run.latestManagerVersion,
342
+ operationId: run.operationId
343
+ };
344
+ const result = supportsDedicated
345
+ ? remoteHub.sendCommand(device.deviceId, { command: LIVE_DESK_UPDATE_COMMAND, payload })
346
+ : remoteHub.sendLegacyClientUpdate(device.deviceId, {
347
+ command: buildLegacyUpdateCommand(device, credentials, run.latestClientVersion),
348
+ timeoutMs: 30_000
349
+ });
350
+ if (result?.ok !== true) {
351
+ target.state = 'failed';
352
+ target.error = String(result?.error || 'client-update-dispatch-failed');
353
+ return false;
354
+ }
355
+ target.state = 'waiting';
356
+ target.method = supportsDedicated ? 'dedicated' : 'legacy-command-run';
357
+ target.commandId = String(result.commandId || result.taskId || '');
358
+ return true;
359
+ };
360
+
361
+ const startUpdate = async () => {
362
+ if (run && ['dispatching', 'waiting-for-clients', 'hub-restart-requested'].includes(run.state)) {
363
+ return { ok: true, ...getStatus() };
364
+ }
365
+ const release = latestRelease || await checkLatest();
366
+ if (!release) return { ok: false, error: checkError || 'LiveDesk update check failed.', ...getStatus() };
367
+ const managerNeedsUpdate = compareVersions(release.latestManagerVersion, currentManagerVersion) > 0;
368
+ const clientPackageNeedsUpdate = compareVersions(release.latestClientVersion, currentClientVersion) > 0;
369
+ const connected = connectedDevices(remoteHub);
370
+ const outdatedClients = connected.filter(device => !isVersionAtLeast(device.agentVersion, release.latestClientVersion));
371
+ const needsHubRestart = managerNeedsUpdate || clientPackageNeedsUpdate;
372
+ if (!managerNeedsUpdate && !clientPackageNeedsUpdate && outdatedClients.length === 0) {
373
+ return { ok: true, state: 'clients-updated', ...getStatus() };
374
+ }
375
+ if (needsHubRestart && !restartSupported) {
376
+ return { ok: false, error: 'Hub launcher restart is unavailable. Start LiveDesk through npx livedesk@latest.', ...getStatus() };
377
+ }
378
+ const targets = outdatedClients;
379
+ const credentials = remoteHub.getStatus({ includeSecrets: true });
380
+ run = {
381
+ operationId: randomUUID(),
382
+ state: 'dispatching',
383
+ startedAt: new Date(now()).toISOString(),
384
+ updatedAt: new Date(now()).toISOString(),
385
+ latestManagerVersion: release.latestManagerVersion,
386
+ latestClientVersion: release.latestClientVersion,
387
+ needsHubRestart,
388
+ error: '',
389
+ targets: targets.map(device => ({
390
+ deviceId: String(device.deviceId || ''),
391
+ deviceName: String(device.deviceName || device.hostname || device.deviceId || ''),
392
+ oldVersion: String(device.agentVersion || ''),
393
+ currentVersion: '',
394
+ state: 'queued',
395
+ method: '',
396
+ commandId: '',
397
+ error: '',
398
+ device
399
+ }))
400
+ };
401
+ for (const target of run.targets) dispatchTarget(target, credentials);
402
+ run.targets = run.targets.map(({ device, ...target }) => target);
403
+ touch();
404
+ if (run.targets.some(target => target.state === 'failed')) {
405
+ failRun('One or more clients could not be scheduled for update.');
406
+ return { ok: false, error: run.error, ...getStatus() };
407
+ }
408
+ if (run.targets.length === 0) {
409
+ if (run.needsHubRestart) {
410
+ requestRestart();
411
+ } else {
412
+ run.state = 'clients-updated';
413
+ touch();
414
+ }
415
+ return { ok: true, ...getStatus() };
416
+ }
417
+ run.state = 'waiting-for-clients';
418
+ runTimer = setInterval(verifyTargets, 1000);
419
+ runTimer.unref?.();
420
+ verifyTargets();
421
+ return { ok: true, ...getStatus() };
422
+ };
423
+
424
+ const handleRemoteEvent = (type, event) => {
425
+ if (!run || run.state !== 'waiting-for-clients' || type !== 'RemoteCommandResult') return;
426
+ const deviceId = String(event?.device?.deviceId || '');
427
+ const commandId = String(event?.commandId || '');
428
+ const target = run.targets.find(item => item.deviceId === deviceId && item.commandId === commandId);
429
+ if (!target || !['dedicated', 'legacy-command-run'].includes(target.method)) return;
430
+ const result = event?.result;
431
+ if (event?.error || result?.ok === false || result?.status === 'failed' || result?.status === 'rejected') {
432
+ target.state = 'failed';
433
+ target.error = String(event.error || result?.error || result?.message || 'client-update-command-failed').slice(0, 500);
434
+ touch();
435
+ failRun(target.method === 'dedicated'
436
+ ? `Client ${target.deviceName || target.deviceId} rejected the update request: ${target.error}`
437
+ : `Client ${target.deviceName || target.deviceId} failed the update command: ${target.error}`);
438
+ }
439
+ };
440
+
441
+ checkTimer = setInterval(() => { void checkLatest(); }, LIVE_DESK_UPDATE_CHECK_INTERVAL_MS);
442
+ checkTimer.unref?.();
443
+ void checkLatest();
444
+
445
+ return {
446
+ checkLatest,
447
+ getStatus,
448
+ startUpdate,
449
+ handleRemoteEvent,
450
+ close() {
451
+ if (checkTimer) clearInterval(checkTimer);
452
+ if (runTimer) clearInterval(runTimer);
453
+ checkTimer = null;
454
+ runTimer = null;
455
+ }
456
+ };
457
+ }
@@ -10,6 +10,8 @@ export class Mode4AtlasSession {
10
10
  this.onStatus = typeof options.onStatus === 'function' ? options.onStatus : () => {};
11
11
  this.deviceIds = [];
12
12
  this.deviceSet = new Set();
13
+ this.inputDeviceIds = [];
14
+ this.inputDeviceSet = new Set();
13
15
  this.config = null;
14
16
  this.closed = false;
15
17
  this.worker = null;
@@ -65,7 +67,10 @@ export class Mode4AtlasSession {
65
67
  this.deviceIds = [...new Set((Array.isArray(options.deviceIds) ? options.deviceIds : [])
66
68
  .map(value => String(value || '').trim()).filter(Boolean))].slice(0, 100);
67
69
  this.deviceSet = new Set(this.deviceIds);
68
- this.pendingFrames = new Map([...this.pendingFrames].filter(([deviceId]) => this.deviceSet.has(deviceId)));
70
+ this.inputDeviceIds = [...new Set((Array.isArray(options.inputDeviceIds) ? options.inputDeviceIds : this.deviceIds)
71
+ .map(value => String(value || '').trim()).filter(value => this.deviceSet.has(value)))].slice(0, 100);
72
+ this.inputDeviceSet = new Set(this.inputDeviceIds);
73
+ this.pendingFrames = new Map([...this.pendingFrames].filter(([deviceId]) => this.inputDeviceSet.has(deviceId)));
69
74
  this.config = {
70
75
  deviceIds: this.deviceIds,
71
76
  width: Number(options.width || 1920),
@@ -102,7 +107,7 @@ export class Mode4AtlasSession {
102
107
  const frame = frameEvent?.frame || {};
103
108
  const deviceId = String(frameEvent?.deviceId || frame.deviceId || '').trim();
104
109
  const mode = String(frame.frameMode || frame.mode || '').toLowerCase();
105
- if (!this.deviceSet.has(deviceId) || mode !== 'mode2-lzo' || !Buffer.isBuffer(frameEvent?.payload)) return;
110
+ if (!this.inputDeviceSet.has(deviceId) || mode !== 'mode2-lzo' || !Buffer.isBuffer(frameEvent?.payload)) return;
106
111
  this.pendingFrames.set(deviceId, {
107
112
  type: 'frame',
108
113
  deviceId,