livedesk 0.1.384 → 0.1.385

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.
@@ -1,25 +1,59 @@
1
1
  import { randomUUID } from 'node:crypto';
2
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;
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_CLIENT_BATCH_SIZE = 5;
8
+ export const LIVE_DESK_UPDATE_TARGET_TIMEOUT_MS = 300_000;
9
+ export const LIVE_DESK_UPDATE_TIMEOUT_MS = 60 * 60_000;
10
+ export const LIVE_DESK_UPDATE_CHECK_INTERVAL_MS = 60_000;
9
11
 
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;
12
+ function cleanVersion(value) {
13
+ return String(value || '').trim().replace(/^v/i, '');
14
+ }
15
+
16
+ function parseVersion(value) {
17
+ const cleaned = cleanVersion(value);
18
+ const match = cleaned.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
19
+ if (!match) return null;
20
+ return {
21
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
22
+ prerelease: match[4] ? match[4].split('.') : []
23
+ };
24
+ }
25
+
26
+ function comparePrerelease(left, right) {
27
+ if (left.length === 0 && right.length === 0) return 0;
28
+ if (left.length === 0) return 1;
29
+ if (right.length === 0) return -1;
30
+ const length = Math.max(left.length, right.length);
31
+ for (let index = 0; index < length; index += 1) {
32
+ if (left[index] === undefined) return -1;
33
+ if (right[index] === undefined) return 1;
34
+ const leftNumeric = /^\d+$/.test(left[index]);
35
+ const rightNumeric = /^\d+$/.test(right[index]);
36
+ if (leftNumeric && rightNumeric) {
37
+ const difference = Number(left[index]) - Number(right[index]);
38
+ if (difference !== 0) return difference;
39
+ continue;
40
+ }
41
+ if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
42
+ const difference = left[index].localeCompare(right[index], 'en');
43
+ if (difference !== 0) return difference;
44
+ }
45
+ return 0;
46
+ }
47
+
48
+ export function compareVersions(left, right) {
49
+ const a = parseVersion(left);
50
+ const b = parseVersion(right);
51
+ if (!a || !b) return cleanVersion(left).localeCompare(cleanVersion(right), 'en');
52
+ for (let index = 0; index < 3; index += 1) {
53
+ const difference = a.core[index] - b.core[index];
54
+ if (difference !== 0) return difference;
55
+ }
56
+ return comparePrerelease(a.prerelease, b.prerelease);
23
57
  }
24
58
 
25
59
  export function isVersionAtLeast(candidate, required) {
@@ -34,7 +68,7 @@ function quotePowerShell(value) {
34
68
  return `'${String(value ?? '').replaceAll("'", "''")}'`;
35
69
  }
36
70
 
37
- function buildWindowsLegacyUpdateCommand({ manager, pair, name, slot, targetVersion }) {
71
+ function buildWindowsLegacyUpdateCommand({ manager, pair, name, slot, targetVersion, targetProductVersion }) {
38
72
  const script = [
39
73
  '$ErrorActionPreference = "Stop"',
40
74
  '$cursor = Get-CimInstance Win32_Process -Filter ("ProcessId={0}" -f $PID)',
@@ -54,8 +88,9 @@ function buildWindowsLegacyUpdateCommand({ manager, pair, name, slot, targetVers
54
88
  `$env:LIVEDESK_CLIENT_MANAGER = ${quotePowerShell(manager)}`,
55
89
  `$env:LIVEDESK_CLIENT_PAIR_TOKEN = ${quotePowerShell(pair)}`,
56
90
  `$env:LIVEDESK_CLIENT_NAME = ${quotePowerShell(name)}`,
57
- `$env:LIVEDESK_CLIENT_SLOT = ${quotePowerShell(slot || '')}`,
58
- `$env:LIVEDESK_CLIENT_UPDATE_TARGET_VERSION = ${quotePowerShell(targetVersion)}`,
91
+ `$env:LIVEDESK_CLIENT_SLOT = ${quotePowerShell(slot || '')}`,
92
+ `$env:LIVEDESK_CLIENT_UPDATE_TARGET_VERSION = ${quotePowerShell(targetVersion)}`,
93
+ `$env:LIVEDESK_CLIENT_UPDATE_TARGET_PRODUCT_VERSION = ${quotePowerShell(targetProductVersion)}`,
59
94
  '$node = [string]$env:LIVEDESK_NODE_EXECUTABLE',
60
95
  'if ($node -and -not (Test-Path -LiteralPath $node -PathType Leaf)) { $node = "" }',
61
96
  'if (-not $node) { $node = (Get-Command node.exe -ErrorAction SilentlyContinue).Source }',
@@ -88,7 +123,9 @@ function buildUnixClientUpdateBootstrapScript() {
88
123
  'const npmExecPath = String(process.env.npm_execpath || process.env.NPM_EXECPATH || "").trim();',
89
124
  '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
125
  'const npxCli = npxCliCandidates.find(candidate => fs.existsSync(candidate));',
91
- 'const npxArgs = ["-y", "--prefer-online", "livedesk@latest", "--force-role", "client", "--no-login", "--no-open"];',
126
+ 'const targetProductVersion = String(process.env.LIVEDESK_CLIENT_UPDATE_TARGET_PRODUCT_VERSION || "").trim();',
127
+ 'const packageSpec = /^\\d+\\.\\d+\\.\\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(targetProductVersion) ? "livedesk@" + targetProductVersion : "livedesk@latest";',
128
+ 'const npxArgs = ["-y", "--prefer-online", packageSpec, "--force-role", "client", "--no-login", "--no-open"];',
92
129
  'const command = npxCli ? process.execPath : (process.platform === "win32" ? (process.env.ComSpec || "cmd.exe") : npx);',
93
130
  'const commandArgs = npxCli ? [npxCli, ...npxArgs] : (process.platform === "win32" ? ["/d", "/s", "/c", "call \\"" + npx + "\\" " + npxArgs.join(" ")] : npxArgs);',
94
131
  'process.env.LIVEDESK_SKIP_BROWSER_OPEN = "1";',
@@ -97,7 +134,7 @@ function buildUnixClientUpdateBootstrapScript() {
97
134
  ].join('\n');
98
135
  }
99
136
 
100
- function buildUnixLegacyUpdateCommand({ manager, pair, name, slot, targetVersion }) {
137
+ function buildUnixLegacyUpdateCommand({ manager, pair, name, slot, targetVersion, targetProductVersion }) {
101
138
  const script = [
102
139
  'const { execFileSync, spawn } = require("node:child_process");',
103
140
  'const fs = require("node:fs");',
@@ -115,8 +152,9 @@ function buildUnixLegacyUpdateCommand({ manager, pair, name, slot, targetVersion
115
152
  `process.env.LIVEDESK_CLIENT_MANAGER = ${JSON.stringify(String(manager || ''))};`,
116
153
  `process.env.LIVEDESK_CLIENT_PAIR_TOKEN = ${JSON.stringify(String(pair || ''))};`,
117
154
  `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 || ''))};`,
155
+ `process.env.LIVEDESK_CLIENT_SLOT = ${JSON.stringify(String(slot || ''))};`,
156
+ `process.env.LIVEDESK_CLIENT_UPDATE_TARGET_VERSION = ${JSON.stringify(String(targetVersion || ''))};`,
157
+ `process.env.LIVEDESK_CLIENT_UPDATE_TARGET_PRODUCT_VERSION = ${JSON.stringify(String(targetProductVersion || ''))};`,
120
158
  `const bootstrapScript = ${JSON.stringify(buildUnixClientUpdateBootstrapScript())};`,
121
159
  'const bootstrapEnv = { ...process.env, LIVEDESK_UPDATE_WAIT_PID: String(waitPid), LIVEDESK_UPDATE_AGENT_PID: String(agentPid) };',
122
160
  'const updater = spawn(process.execPath, ["-e", bootstrapScript], { detached: true, stdio: "ignore", env: bootstrapEnv });',
@@ -130,13 +168,14 @@ function buildUnixLegacyUpdateCommand({ manager, pair, name, slot, targetVersion
130
168
  ].join('\n');
131
169
  }
132
170
 
133
- function buildLegacyUpdateCommand(device, credentials, targetVersion) {
134
- const payload = {
135
- manager: credentials.manager,
171
+ function buildLegacyUpdateCommand(device, credentials, targetVersion, targetProductVersion) {
172
+ const payload = {
173
+ manager: credentials.agentEndpoint || credentials.manager,
136
174
  pair: credentials.pairToken,
137
175
  name: device.deviceName || device.hostname || '',
138
176
  slot: device.slotNumber || '',
139
- targetVersion
177
+ targetVersion,
178
+ targetProductVersion
140
179
  };
141
180
  return ['win32', 'windows'].includes(String(device.platform || '').toLowerCase())
142
181
  ? buildWindowsLegacyUpdateCommand(payload)
@@ -158,58 +197,103 @@ async function fetchLatestPackage(packageName, fetchImpl) {
158
197
  return { version, package: payload };
159
198
  }
160
199
 
161
- export async function fetchLatestLiveDeskRelease(fetchImpl = globalThis.fetch) {
200
+ export async function fetchLatestLiveDeskRelease(fetchImpl = globalThis.fetch) {
162
201
  if (typeof fetchImpl !== 'function') throw new Error('fetch is unavailable for LiveDesk update checks.');
163
202
  const [manager, client] = await Promise.all([
164
203
  fetchLatestPackage('livedesk', fetchImpl),
165
204
  fetchLatestPackage('@livedesk/client', fetchImpl)
166
205
  ]);
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
- }
206
+ const bundledClientVersion = cleanVersion(manager.package?.livedeskClientVersion);
207
+ if (!bundledClientVersion) {
208
+ throw new Error(`livedesk@${manager.version} is missing required livedeskClientVersion release metadata.`);
209
+ }
210
+ return {
211
+ latestManagerVersion: manager.version,
212
+ latestClientVersion: bundledClientVersion,
213
+ registryClientVersion: client.version,
214
+ releaseSkew: compareVersions(client.version, bundledClientVersion) === 0
215
+ ? ''
216
+ : `livedesk@${manager.version} bundles Client ${bundledClientVersion}, while @livedesk/client latest is ${client.version}.`,
217
+ managerPackage: manager.package,
218
+ clientPackage: client.package,
219
+ checkedAt: new Date().toISOString()
220
+ };
221
+ }
175
222
 
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
- }
223
+ function statusForRun(run) {
224
+ if (!run) return null;
225
+ const completedCount = run.targets.filter(target => target.state === 'completed').length;
226
+ const failedCount = run.targets.filter(target => target.state === 'failed').length;
227
+ const queuedCount = run.targets.filter(target => target.state === 'queued').length;
228
+ const pendingOfflineCount = run.targets.filter(target => target.state === 'pending-offline').length;
229
+ const activeCount = run.targets.filter(target => target.state === 'waiting').length;
230
+ return {
231
+ operationId: run.operationId,
232
+ state: run.state,
233
+ startedAt: run.startedAt,
234
+ updatedAt: run.updatedAt,
235
+ targetCount: run.targets.length,
236
+ completedCount,
237
+ waitingCount: run.targets.length - completedCount - failedCount,
238
+ activeCount,
239
+ queuedCount,
240
+ pendingOfflineCount,
241
+ failedCount,
242
+ batchSize: run.batchSize,
243
+ latestManagerVersion: run.latestManagerVersion,
244
+ latestClientVersion: run.latestClientVersion,
245
+ error: run.error || '',
246
+ targets: run.targets.map(target => ({ ...target }))
247
+ };
248
+ }
191
249
 
192
- function connectedDevices(remoteHub) {
250
+ function connectedDevices(remoteHub) {
193
251
  return remoteHub.listDevices({ includeDataUrl: false })
194
252
  .filter(device => device.connected === true && device.synthetic !== true)
195
253
  .slice(0, 500);
196
- }
254
+ }
255
+
256
+ function knownOfflineDevices(remoteHub) {
257
+ return remoteHub.listDevices({ includeDataUrl: false })
258
+ .filter(device => device.connected !== true && device.synthetic !== true)
259
+ .slice(0, 500);
260
+ }
261
+
262
+ function deviceNeedsUpdate(device, latestManagerVersion, latestClientVersion) {
263
+ return !isVersionAtLeast(device?.productVersion, latestManagerVersion)
264
+ || !isVersionAtLeast(device?.agentVersion, latestClientVersion);
265
+ }
197
266
 
198
267
  export function createLiveDeskUpdateManager({
199
268
  remoteHub,
200
269
  currentManagerVersion,
201
- currentClientVersion,
202
- restartSupported = false,
203
- requestHubRestart,
204
- fetchImpl = globalThis.fetch,
205
- now = () => Date.now()
206
- }) {
270
+ currentClientVersion,
271
+ restartSupported = false,
272
+ requestHubRestart,
273
+ fetchImpl = globalThis.fetch,
274
+ now = () => Date.now(),
275
+ clientBatchSize = LIVE_DESK_UPDATE_CLIENT_BATCH_SIZE,
276
+ targetTimeoutMs = LIVE_DESK_UPDATE_TARGET_TIMEOUT_MS,
277
+ operationTimeoutMs = LIVE_DESK_UPDATE_TIMEOUT_MS
278
+ }) {
207
279
  let latestRelease = null;
208
280
  let checkError = '';
209
281
  let checkPromise = null;
210
- let run = null;
211
- let checkTimer = null;
212
- let runTimer = null;
282
+ let run = null;
283
+ let checkTimer = null;
284
+ let runTimer = null;
285
+ const effectiveClientBatchSize = Math.max(
286
+ 1,
287
+ Math.min(50, Math.floor(Number(clientBatchSize) || LIVE_DESK_UPDATE_CLIENT_BATCH_SIZE))
288
+ );
289
+ const effectiveTargetTimeoutMs = Math.max(
290
+ 10_000,
291
+ Number(targetTimeoutMs) || LIVE_DESK_UPDATE_TARGET_TIMEOUT_MS
292
+ );
293
+ const effectiveOperationTimeoutMs = Math.max(
294
+ effectiveTargetTimeoutMs,
295
+ Number(operationTimeoutMs) || LIVE_DESK_UPDATE_TIMEOUT_MS
296
+ );
213
297
 
214
298
  const touch = () => {
215
299
  if (run) run.updatedAt = new Date(now()).toISOString();
@@ -234,45 +318,93 @@ export function createLiveDeskUpdateManager({
234
318
  };
235
319
 
236
320
  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;
321
+ const managerUpdateAvailable = !!latestRelease
322
+ && compareVersions(latestRelease.latestManagerVersion, currentManagerVersion) > 0;
323
+ const clientDevices = connectedDevices(remoteHub);
324
+ const outdatedClientDevices = latestRelease
325
+ ? clientDevices.filter(device => deviceNeedsUpdate(
326
+ device,
327
+ latestRelease.latestManagerVersion,
328
+ latestRelease.latestClientVersion
329
+ ))
330
+ : [];
331
+ const pendingOfflineClientCount = latestRelease
332
+ ? knownOfflineDevices(remoteHub).filter(device => deviceNeedsUpdate(
333
+ device,
334
+ latestRelease.latestManagerVersion,
335
+ latestRelease.latestClientVersion
336
+ )).length
337
+ : 0;
338
+ const clientPackageUpdateAvailable = !!latestRelease
339
+ && compareVersions(latestRelease.latestClientVersion, currentClientVersion) > 0;
245
340
  const clientUpdateAvailable = clientPackageUpdateAvailable || outdatedClientDevices.length > 0;
246
341
  const updateAvailable = managerUpdateAvailable || clientUpdateAvailable;
247
342
  const activeRun = statusForRun(run);
248
343
  return {
249
344
  currentVersion: String(currentManagerVersion || ''),
250
345
  currentClientVersion: String(currentClientVersion || ''),
251
- latestVersion: latestRelease?.latestManagerVersion || '',
252
- latestClientVersion: latestRelease?.latestClientVersion || '',
253
- updateAvailable,
346
+ latestVersion: latestRelease?.latestManagerVersion || '',
347
+ latestClientVersion: latestRelease?.latestClientVersion || '',
348
+ registryClientVersion: latestRelease?.registryClientVersion || '',
349
+ releaseSkew: latestRelease?.releaseSkew || '',
350
+ updateAvailable,
254
351
  managerUpdateAvailable,
255
352
  clientUpdateAvailable,
256
- clientPackageUpdateAvailable,
257
- outdatedClientCount: outdatedClientDevices.length,
258
- checkedAt: latestRelease?.checkedAt || '',
353
+ clientPackageUpdateAvailable,
354
+ outdatedClientCount: outdatedClientDevices.length,
355
+ pendingOfflineClientCount,
356
+ checkedAt: latestRelease?.checkedAt || '',
259
357
  checkError,
260
358
  restartSupported: !!restartSupported,
261
359
  canApply: updateAvailable && (!managerUpdateAvailable && !clientPackageUpdateAvailable || !!restartSupported),
262
- ...(activeRun || { state: 'idle', operationId: '', targetCount: 0, completedCount: 0, waitingCount: 0, failedCount: 0, targets: [] })
360
+ ...(activeRun || {
361
+ state: 'idle',
362
+ operationId: '',
363
+ targetCount: 0,
364
+ completedCount: 0,
365
+ waitingCount: 0,
366
+ activeCount: 0,
367
+ queuedCount: 0,
368
+ pendingOfflineCount: 0,
369
+ failedCount: 0,
370
+ batchSize: effectiveClientBatchSize,
371
+ targets: []
372
+ })
263
373
  };
264
374
  };
265
375
 
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
- };
376
+ const failRun = (message) => {
377
+ if (!run) return;
378
+ run.state = 'failed';
379
+ run.error = String(message || 'LiveDesk update failed.');
380
+ for (const target of run.targets) {
381
+ if (target.state === 'completed') continue;
382
+ target.state = 'failed';
383
+ if (!target.error) {
384
+ target.error = `Rollout stopped: ${run.error}`;
385
+ }
386
+ }
387
+ touch();
388
+ if (runTimer) {
389
+ clearInterval(runTimer);
390
+ runTimer = null;
391
+ }
392
+ };
393
+
394
+ const retrySettlingTargets = () => {
395
+ if (!run || run.state !== 'failed') return [];
396
+ const currentTime = now();
397
+ const devices = new Map(
398
+ remoteHub.listDevices({ includeDataUrl: false })
399
+ .map(device => [String(device.deviceId), device])
400
+ );
401
+ return run.targets.filter(target => {
402
+ if (target.state !== 'failed' || !target.dispatchedAt) return false;
403
+ const deadline = Date.parse(target.deadlineAt || '');
404
+ if (!(deadline > currentTime)) return false;
405
+ return devices.get(target.deviceId)?.connected !== true;
406
+ });
407
+ };
276
408
 
277
409
  const requestRestart = () => {
278
410
  if (!restartSupported) {
@@ -281,8 +413,8 @@ export function createLiveDeskUpdateManager({
281
413
  }
282
414
  const result = requestHubRestart?.({
283
415
  operationId: run?.operationId || '',
284
- latestVersion: latestRelease?.latestManagerVersion || '',
285
- latestClientVersion: latestRelease?.latestClientVersion || '',
416
+ latestVersion: run?.latestManagerVersion || latestRelease?.latestManagerVersion || '',
417
+ latestClientVersion: run?.latestClientVersion || latestRelease?.latestClientVersion || '',
286
418
  // Current versions update every connected Client before the Hub exits.
287
419
  // Older Hub versions did the reverse and set this flag themselves; the
288
420
  // new server still supports that one-time migration path.
@@ -299,75 +431,155 @@ export function createLiveDeskUpdateManager({
299
431
  return true;
300
432
  };
301
433
 
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() };
434
+ const dispatchTarget = (target, device, credentials) => {
435
+ const supportsDedicated = device.capabilities?.clientUpdate === true
436
+ && isVersionAtLeast(device.agentVersion, LIVE_DESK_DEDICATED_CLIENT_UPDATE_MIN_VERSION);
437
+ const payload = {
438
+ targetVersion: run.latestClientVersion,
439
+ targetProductVersion: run.latestManagerVersion,
440
+ managerVersion: run.latestManagerVersion,
441
+ operationId: run.operationId
442
+ };
443
+ const result = supportsDedicated
444
+ ? remoteHub.sendCommand(device.deviceId, { command: LIVE_DESK_UPDATE_COMMAND, payload })
445
+ : remoteHub.sendLegacyClientUpdate(device.deviceId, {
446
+ command: buildLegacyUpdateCommand(
447
+ device,
448
+ credentials,
449
+ run.latestClientVersion,
450
+ run.latestManagerVersion
451
+ ),
452
+ timeoutMs: 30_000
453
+ });
454
+ if (result?.ok !== true) {
455
+ const dispatchError = String(result?.error || 'client-update-dispatch-failed');
456
+ if (['device-not-connected', 'device-not-found'].includes(dispatchError)) {
457
+ target.state = 'pending-offline';
458
+ target.method = '';
459
+ target.commandId = '';
460
+ target.dispatchedAt = '';
461
+ target.deadlineAt = '';
462
+ target.error = '';
463
+ return 'pending-offline';
464
+ }
465
+ target.state = 'failed';
466
+ target.error = dispatchError;
467
+ return false;
364
468
  }
365
- const release = latestRelease || await checkLatest();
469
+ target.state = 'waiting';
470
+ target.method = supportsDedicated ? 'dedicated' : 'legacy-command-run';
471
+ target.commandId = String(result.commandId || result.taskId || '');
472
+ target.dispatchedAt = new Date(now()).toISOString();
473
+ target.deadlineAt = new Date(now() + effectiveTargetTimeoutMs).toISOString();
474
+ return true;
475
+ };
476
+
477
+ const dispatchQueuedTargets = () => {
478
+ if (!run || run.state !== 'waiting-for-clients') return true;
479
+ const activeCount = run.targets.filter(target => target.state === 'waiting').length;
480
+ let availableSlots = Math.max(0, run.batchSize - activeCount);
481
+ if (availableSlots <= 0) return true;
482
+ const devices = new Map(connectedDevices(remoteHub).map(device => [String(device.deviceId), device]));
483
+ const credentials = remoteHub.getStatus({ includeSecrets: true });
484
+ for (const target of run.targets) {
485
+ if (availableSlots <= 0) break;
486
+ if (!['queued', 'pending-offline'].includes(target.state)) continue;
487
+ const device = devices.get(target.deviceId);
488
+ if (!device?.connected) {
489
+ target.state = 'pending-offline';
490
+ continue;
491
+ }
492
+ target.state = 'queued';
493
+ const dispatchResult = dispatchTarget(target, device, credentials);
494
+ if (dispatchResult === false) {
495
+ failRun(`Client ${target.deviceName || target.deviceId} could not be scheduled: ${target.error}`);
496
+ return false;
497
+ }
498
+ if (dispatchResult === true) {
499
+ availableSlots -= 1;
500
+ }
501
+ }
502
+ touch();
503
+ return true;
504
+ };
505
+
506
+ const verifyTargets = () => {
507
+ if (!run || run.state !== 'waiting-for-clients') return;
508
+ const currentTime = now();
509
+ if (currentTime - Date.parse(run.startedAt) >= effectiveOperationTimeoutMs) {
510
+ const unfinishedCount = run.targets.filter(target => target.state !== 'completed').length;
511
+ failRun(`Timed out waiting for ${unfinishedCount} client(s) to finish the rollout.`);
512
+ return;
513
+ }
514
+ const devices = new Map(connectedDevices(remoteHub).map(device => [String(device.deviceId), device]));
515
+ for (const target of run.targets) {
516
+ if (target.state === 'failed' || target.state === 'completed') continue;
517
+ const device = devices.get(target.deviceId);
518
+ if (target.state === 'waiting'
519
+ && device?.connected === true
520
+ && !deviceNeedsUpdate(device, run.latestManagerVersion, run.latestClientVersion)) {
521
+ target.state = 'completed';
522
+ target.currentVersion = String(device.agentVersion || '');
523
+ target.currentProductVersion = String(device.productVersion || '');
524
+ target.completedAt = new Date(currentTime).toISOString();
525
+ continue;
526
+ }
527
+ if (target.state === 'waiting'
528
+ && Date.parse(target.deadlineAt || '') > 0
529
+ && currentTime >= Date.parse(target.deadlineAt)) {
530
+ target.state = 'failed';
531
+ target.error = `Timed out waiting for product ${run.latestManagerVersion} / Client ${run.latestClientVersion}.`;
532
+ failRun(`Client ${target.deviceName || target.deviceId} did not restart on the requested versions within ${Math.round(effectiveTargetTimeoutMs / 1000)} seconds.`);
533
+ return;
534
+ }
535
+ if (target.state === 'pending-offline' && device?.connected === true) {
536
+ target.state = 'queued';
537
+ }
538
+ }
539
+ if (!dispatchQueuedTargets()) return;
540
+ touch();
541
+ if (run.targets.every(target => target.state === 'completed')) {
542
+ if (runTimer) {
543
+ clearInterval(runTimer);
544
+ runTimer = null;
545
+ }
546
+ if (run.needsHubRestart) {
547
+ requestRestart();
548
+ } else {
549
+ run.state = 'clients-updated';
550
+ touch();
551
+ }
552
+ return;
553
+ }
554
+ };
555
+
556
+ const startUpdate = async () => {
557
+ if (run && ['dispatching', 'waiting-for-clients', 'hub-restart-requested'].includes(run.state)) {
558
+ return { ok: true, ...getStatus() };
559
+ }
560
+ const settlingTargets = retrySettlingTargets();
561
+ if (settlingTargets.length > 0) {
562
+ const names = settlingTargets
563
+ .slice(0, 3)
564
+ .map(target => target.deviceName || target.deviceId)
565
+ .join(', ');
566
+ const remaining = settlingTargets.length > 3 ? ` and ${settlingTargets.length - 3} more` : '';
567
+ return {
568
+ ...getStatus(),
569
+ ok: false,
570
+ error: `The previous rollout is still settling for offline Client(s): ${names}${remaining}. Retry after they reconnect or their restart deadline passes.`
571
+ };
572
+ }
573
+ const release = latestRelease || await checkLatest();
366
574
  if (!release) return { ok: false, error: checkError || 'LiveDesk update check failed.', ...getStatus() };
367
575
  const managerNeedsUpdate = compareVersions(release.latestManagerVersion, currentManagerVersion) > 0;
368
576
  const clientPackageNeedsUpdate = compareVersions(release.latestClientVersion, currentClientVersion) > 0;
369
577
  const connected = connectedDevices(remoteHub);
370
- const outdatedClients = connected.filter(device => !isVersionAtLeast(device.agentVersion, release.latestClientVersion));
578
+ const outdatedClients = connected.filter(device => deviceNeedsUpdate(
579
+ device,
580
+ release.latestManagerVersion,
581
+ release.latestClientVersion
582
+ ));
371
583
  const needsHubRestart = managerNeedsUpdate || clientPackageNeedsUpdate;
372
584
  if (!managerNeedsUpdate && !clientPackageNeedsUpdate && outdatedClients.length === 0) {
373
585
  return { ok: true, state: 'clients-updated', ...getStatus() };
@@ -376,46 +588,49 @@ export function createLiveDeskUpdateManager({
376
588
  return { ok: false, error: 'Hub launcher restart is unavailable. Start LiveDesk through npx livedesk@latest.', ...getStatus() };
377
589
  }
378
590
  const targets = outdatedClients;
379
- const credentials = remoteHub.getStatus({ includeSecrets: true });
380
- run = {
591
+ run = {
381
592
  operationId: randomUUID(),
382
593
  state: 'dispatching',
383
594
  startedAt: new Date(now()).toISOString(),
384
595
  updatedAt: new Date(now()).toISOString(),
385
596
  latestManagerVersion: release.latestManagerVersion,
386
- latestClientVersion: release.latestClientVersion,
597
+ latestClientVersion: release.latestClientVersion,
387
598
  needsHubRestart,
388
- error: '',
389
- targets: targets.map(device => ({
599
+ batchSize: effectiveClientBatchSize,
600
+ error: '',
601
+ targets: targets.map(device => ({
390
602
  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 {
603
+ deviceName: String(device.deviceName || device.hostname || device.deviceId || ''),
604
+ oldVersion: String(device.agentVersion || ''),
605
+ oldProductVersion: String(device.productVersion || ''),
606
+ currentVersion: '',
607
+ currentProductVersion: '',
608
+ state: 'queued',
609
+ method: '',
610
+ commandId: '',
611
+ dispatchedAt: '',
612
+ deadlineAt: '',
613
+ completedAt: '',
614
+ error: ''
615
+ }))
616
+ };
617
+ touch();
618
+ if (run.targets.length === 0) {
619
+ if (run.needsHubRestart) {
620
+ if (!requestRestart()) {
621
+ return { ok: false, error: run.error, ...getStatus() };
622
+ }
623
+ } else {
412
624
  run.state = 'clients-updated';
413
625
  touch();
414
626
  }
415
627
  return { ok: true, ...getStatus() };
416
- }
417
- run.state = 'waiting-for-clients';
418
- runTimer = setInterval(verifyTargets, 1000);
628
+ }
629
+ run.state = 'waiting-for-clients';
630
+ if (!dispatchQueuedTargets()) {
631
+ return { ok: false, error: run.error, ...getStatus() };
632
+ }
633
+ runTimer = setInterval(verifyTargets, 1000);
419
634
  runTimer.unref?.();
420
635
  verifyTargets();
421
636
  return { ok: true, ...getStatus() };
@@ -426,7 +641,9 @@ export function createLiveDeskUpdateManager({
426
641
  const deviceId = String(event?.device?.deviceId || '');
427
642
  const commandId = String(event?.commandId || '');
428
643
  const target = run.targets.find(item => item.deviceId === deviceId && item.commandId === commandId);
429
- if (!target || !['dedicated', 'legacy-command-run'].includes(target.method)) return;
644
+ if (!target
645
+ || target.state !== 'waiting'
646
+ || !['dedicated', 'legacy-command-run'].includes(target.method)) return;
430
647
  const result = event?.result;
431
648
  if (event?.error || result?.ok === false || result?.status === 'failed' || result?.status === 'rejected') {
432
649
  target.state = 'failed';