livedesk 0.1.667 → 0.1.669

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/client",
3
- "version": "0.1.272",
3
+ "version": "0.1.273",
4
4
  "description": "VuvoDesk local remote client",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,10 +42,10 @@
42
42
  "ws": "^8.18.3"
43
43
  },
44
44
  "optionalDependencies": {
45
- "@livedesk/fast-linux-x64": "0.1.467",
46
- "@livedesk/fast-osx-arm64": "0.1.467",
47
- "@livedesk/fast-osx-x64": "0.1.467",
48
- "@livedesk/fast-win-x64": "0.1.467"
45
+ "@livedesk/fast-linux-x64": "0.1.468",
46
+ "@livedesk/fast-osx-arm64": "0.1.468",
47
+ "@livedesk/fast-osx-x64": "0.1.468",
48
+ "@livedesk/fast-win-x64": "0.1.468"
49
49
  },
50
50
  "publishConfig": {
51
51
  "access": "public"
@@ -0,0 +1,407 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { PassThrough } from 'node:stream';
4
+
5
+ const require = createRequire(import.meta.url);
6
+
7
+ export const SUPPORTED_ELECTRON_UPDATER_VERSION = '6.8.9';
8
+ export const MAX_MULTIPART_RANGE_COUNT = 48;
9
+ export const MAX_MULTIPART_RANGE_BYTES = 32 * 1024 * 1024;
10
+
11
+ const COPY_OPERATION_KIND = 0;
12
+ const DOWNLOAD_OPERATION_KIND = 1;
13
+ const PATCH_MARKER = Symbol.for('vuvodesk.bounded-multipart-range-batching.patch');
14
+ const UPDATER_CAPABILITY = Symbol.for('vuvodesk.bounded-multipart-range-batching.capability');
15
+
16
+ function operationLength(task) {
17
+ const start = Number(task?.start);
18
+ const end = Number(task?.end);
19
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end <= start) {
20
+ throw new Error('desktop-update-invalid-differential-operation-range');
21
+ }
22
+ if (task.kind !== COPY_OPERATION_KIND && task.kind !== DOWNLOAD_OPERATION_KIND) {
23
+ throw new Error('desktop-update-invalid-differential-operation-kind');
24
+ }
25
+ return end - start;
26
+ }
27
+
28
+ export function planBoundedMultipartRangeBatches(tasks) {
29
+ if (!Array.isArray(tasks)) throw new Error('desktop-update-invalid-differential-plan');
30
+ if (tasks.length === 0) return [];
31
+
32
+ const batches = [];
33
+ let start = 0;
34
+ let rangeCount = 0;
35
+ let downloadBytes = 0;
36
+
37
+ const commit = end => {
38
+ if (end <= start) return;
39
+ batches.push({
40
+ start,
41
+ end,
42
+ rangeCount,
43
+ downloadBytes,
44
+ useMultipart: rangeCount > 1
45
+ });
46
+ start = end;
47
+ rangeCount = 0;
48
+ downloadBytes = 0;
49
+ };
50
+
51
+ for (let index = 0; index < tasks.length; index += 1) {
52
+ const task = tasks[index];
53
+ const length = operationLength(task);
54
+ if (task.kind !== DOWNLOAD_OPERATION_KIND) continue;
55
+
56
+ const exceedsCount = rangeCount >= MAX_MULTIPART_RANGE_COUNT;
57
+ const exceedsBytes = rangeCount > 0 && downloadBytes + length > MAX_MULTIPART_RANGE_BYTES;
58
+ if (exceedsCount || exceedsBytes) commit(index);
59
+
60
+ rangeCount += 1;
61
+ downloadBytes += length;
62
+ }
63
+ commit(tasks.length);
64
+
65
+ return batches;
66
+ }
67
+
68
+ function createProgressReporter(differentialDownloader, totalBytes) {
69
+ const onProgress = differentialDownloader.options?.onProgress;
70
+ const startedAt = Date.now();
71
+ let transferred = 0;
72
+
73
+ return nextTransferred => {
74
+ if (typeof onProgress !== 'function' || totalBytes <= 0) return;
75
+ const next = Math.max(transferred, Math.min(totalBytes, Number(nextTransferred) || 0));
76
+ const delta = next - transferred;
77
+ transferred = next;
78
+ const elapsedSeconds = Math.max(0.001, (Date.now() - startedAt) / 1000);
79
+ onProgress({
80
+ total: totalBytes,
81
+ delta,
82
+ transferred,
83
+ percent: (transferred / totalBytes) * 100,
84
+ bytesPerSecond: Math.round(transferred / elapsedSeconds)
85
+ });
86
+ };
87
+ }
88
+
89
+ function createExecutor({ DataSplitter, checkIsRangesSupported, CancellationError, safeGetHeader }) {
90
+ return function executeTasksUsingBoundedMultipartRangeRequests(
91
+ differentialDownloader,
92
+ tasks,
93
+ out,
94
+ oldFileFd,
95
+ reject
96
+ ) {
97
+ const batches = planBoundedMultipartRangeBatches(tasks);
98
+ const totalRangeCount = batches.reduce((total, batch) => total + batch.rangeCount, 0);
99
+ const totalDownloadBytes = batches.reduce((total, batch) => total + batch.downloadBytes, 0);
100
+ const reportProgress = createProgressReporter(differentialDownloader, totalDownloadBytes);
101
+ const cancellationToken = differentialDownloader.options?.cancellationToken;
102
+ const logger = differentialDownloader.logger;
103
+ const outputGate = new PassThrough();
104
+ outputGate.pipe(out, { end: false });
105
+
106
+ let started = false;
107
+ let active = true;
108
+ let batchIndex = 0;
109
+ let completedDownloadBytes = 0;
110
+ let currentRequest = null;
111
+ let currentResponse = null;
112
+ let currentCopyStream = null;
113
+ let currentSplitter = null;
114
+ let currentPostResponseTimer = null;
115
+
116
+ logger?.info?.(
117
+ `Differential range batching: ranges=${totalRangeCount}, batches=${batches.length}, `
118
+ + `downloadBytes=${totalDownloadBytes}, maxRanges=${MAX_MULTIPART_RANGE_COUNT}, `
119
+ + `maxBytes=${MAX_MULTIPART_RANGE_BYTES}`
120
+ );
121
+
122
+ const removeCancellationListener = () => {
123
+ if (typeof cancellationToken?.removeListener === 'function') {
124
+ cancellationToken.removeListener('cancel', cancelCurrentOwner);
125
+ }
126
+ };
127
+
128
+ const stopCurrentResources = () => {
129
+ try { currentRequest?.abort?.(); } catch { /* already terminal */ }
130
+ try { currentResponse?.destroy?.(); } catch { /* already terminal */ }
131
+ try { currentCopyStream?.destroy?.(); } catch { /* already terminal */ }
132
+ try { currentSplitter?.destroy?.(); } catch { /* already terminal */ }
133
+ if (currentPostResponseTimer) clearTimeout(currentPostResponseTimer);
134
+ currentRequest = null;
135
+ currentResponse = null;
136
+ currentCopyStream = null;
137
+ currentSplitter = null;
138
+ currentPostResponseTimer = null;
139
+ };
140
+
141
+ const rejectCurrentOwner = error => {
142
+ if (!active) return;
143
+ active = false;
144
+ removeCancellationListener();
145
+ stopCurrentResources();
146
+ outputGate.unpipe(out);
147
+ outputGate.destroy();
148
+ reject(error);
149
+ };
150
+
151
+ function cancelCurrentOwner() {
152
+ rejectCurrentOwner(new CancellationError());
153
+ }
154
+
155
+ const finishOutput = () => {
156
+ if (!active) return;
157
+ active = false;
158
+ removeCancellationListener();
159
+ if (differentialDownloader.fileMetadataBuffer != null) {
160
+ out.write(differentialDownloader.fileMetadataBuffer);
161
+ }
162
+ out.end();
163
+ };
164
+
165
+ const finishTasks = () => {
166
+ if (!active) return;
167
+ outputGate.once('end', finishOutput);
168
+ outputGate.end();
169
+ };
170
+
171
+ const executeSingleRangeBatch = (batch, resolveBatch) => {
172
+ let index = batch.start;
173
+ let batchTransferred = 0;
174
+ let lastProgressAt = Date.now();
175
+
176
+ const next = () => {
177
+ if (!active) return;
178
+ if (cancellationToken?.cancelled) {
179
+ cancelCurrentOwner();
180
+ return;
181
+ }
182
+ if (index >= batch.end) {
183
+ reportProgress(completedDownloadBytes + batch.downloadBytes);
184
+ resolveBatch();
185
+ return;
186
+ }
187
+
188
+ const task = tasks[index++];
189
+ const expectedLength = operationLength(task);
190
+ if (task.kind === COPY_OPERATION_KIND) {
191
+ const readStream = createReadStream('', {
192
+ fd: oldFileFd,
193
+ autoClose: false,
194
+ start: task.start,
195
+ end: task.end - 1
196
+ });
197
+ currentCopyStream = readStream;
198
+ readStream.once('error', rejectCurrentOwner);
199
+ readStream.once('end', () => {
200
+ if (currentCopyStream === readStream) currentCopyStream = null;
201
+ next();
202
+ });
203
+ readStream.pipe(outputGate, { end: false });
204
+ return;
205
+ }
206
+
207
+ const requestOptions = differentialDownloader.createRequestOptions();
208
+ requestOptions.headers.Range = `bytes=${task.start}-${task.end - 1}`;
209
+ const request = differentialDownloader.httpExecutor.createRequest(requestOptions, response => {
210
+ if (!active) {
211
+ response.destroy?.();
212
+ return;
213
+ }
214
+ currentResponse = response;
215
+ let received = 0;
216
+ response.once('error', rejectCurrentOwner);
217
+ response.once('aborted', () => rejectCurrentOwner(new Error('desktop-update-range-response-aborted')));
218
+ if (!checkIsRangesSupported(response, rejectCurrentOwner)) return;
219
+ response.on('data', chunk => {
220
+ received += chunk.length;
221
+ batchTransferred += chunk.length;
222
+ const now = Date.now();
223
+ if (now - lastProgressAt >= 1_000) {
224
+ lastProgressAt = now;
225
+ reportProgress(completedDownloadBytes + batchTransferred);
226
+ }
227
+ });
228
+ response.pipe(outputGate, { end: false });
229
+ response.once('end', () => {
230
+ if (!active) return;
231
+ if (received !== expectedLength) {
232
+ rejectCurrentOwner(new Error('desktop-update-range-length-mismatch'));
233
+ return;
234
+ }
235
+ if (currentRequest === request) currentRequest = null;
236
+ if (currentResponse === response) currentResponse = null;
237
+ reportProgress(completedDownloadBytes + batchTransferred);
238
+ next();
239
+ });
240
+ });
241
+ currentRequest = request;
242
+ differentialDownloader.httpExecutor.addErrorAndTimeoutHandlers(request, rejectCurrentOwner);
243
+ request.end();
244
+ };
245
+
246
+ next();
247
+ };
248
+
249
+ const executeMultipartBatch = (batch, resolveBatch) => {
250
+ const partIndexToTaskIndex = new Map();
251
+ const partIndexToLength = [];
252
+ const ranges = [];
253
+ let partIndex = 0;
254
+
255
+ for (let index = batch.start; index < batch.end; index += 1) {
256
+ const task = tasks[index];
257
+ if (task.kind !== DOWNLOAD_OPERATION_KIND) continue;
258
+ ranges.push(`${task.start}-${task.end - 1}`);
259
+ partIndexToTaskIndex.set(partIndex, index);
260
+ partIndexToLength.push(operationLength(task));
261
+ partIndex += 1;
262
+ }
263
+
264
+ const requestOptions = differentialDownloader.createRequestOptions();
265
+ requestOptions.headers.Range = `bytes=${ranges.join(', ')}`;
266
+ const request = differentialDownloader.httpExecutor.createRequest(requestOptions, response => {
267
+ if (!active) {
268
+ response.destroy?.();
269
+ return;
270
+ }
271
+ currentResponse = response;
272
+ let parsed = false;
273
+ let responseEnded = false;
274
+
275
+ const settleIfComplete = () => {
276
+ if (!active || !parsed || !responseEnded) return;
277
+ if (currentPostResponseTimer) clearTimeout(currentPostResponseTimer);
278
+ currentPostResponseTimer = null;
279
+ if (currentRequest === request) currentRequest = null;
280
+ if (currentResponse === response) currentResponse = null;
281
+ currentSplitter = null;
282
+ reportProgress(completedDownloadBytes + batch.downloadBytes);
283
+ resolveBatch();
284
+ };
285
+
286
+ response.once('error', rejectCurrentOwner);
287
+ response.once('aborted', () => rejectCurrentOwner(new Error('desktop-update-multipart-response-aborted')));
288
+ if (!checkIsRangesSupported(response, rejectCurrentOwner)) return;
289
+ const contentType = String(safeGetHeader(response, 'content-type') || '');
290
+ const match = /^multipart\/.+?\s*;\s*boundary=(?:"([^"]+)"|([^\s";]+))\s*$/i.exec(contentType);
291
+ if (!match) {
292
+ rejectCurrentOwner(new Error('desktop-update-multipart-content-type-invalid'));
293
+ return;
294
+ }
295
+
296
+ const batchProgress = progress => {
297
+ reportProgress(completedDownloadBytes + Math.min(batch.downloadBytes, Number(progress?.transferred) || 0));
298
+ };
299
+ const splitter = new DataSplitter(
300
+ outputGate,
301
+ { tasks, start: batch.start, end: batch.end, oldFileFd },
302
+ partIndexToTaskIndex,
303
+ match[1] || match[2],
304
+ partIndexToLength,
305
+ () => {
306
+ parsed = true;
307
+ settleIfComplete();
308
+ },
309
+ batch.downloadBytes,
310
+ batchProgress
311
+ );
312
+ currentSplitter = splitter;
313
+ splitter.once('error', rejectCurrentOwner);
314
+ response.pipe(splitter);
315
+ response.once('end', () => {
316
+ responseEnded = true;
317
+ if (!parsed) {
318
+ currentPostResponseTimer = setTimeout(() => {
319
+ rejectCurrentOwner(new Error('desktop-update-multipart-response-incomplete'));
320
+ }, 10_000);
321
+ currentPostResponseTimer.unref?.();
322
+ }
323
+ settleIfComplete();
324
+ });
325
+ });
326
+ currentRequest = request;
327
+ differentialDownloader.httpExecutor.addErrorAndTimeoutHandlers(request, rejectCurrentOwner);
328
+ request.end();
329
+ };
330
+
331
+ const runNextBatch = () => {
332
+ if (!active) return;
333
+ if (cancellationToken?.cancelled) {
334
+ cancelCurrentOwner();
335
+ return;
336
+ }
337
+ if (batchIndex >= batches.length) {
338
+ finishTasks();
339
+ return;
340
+ }
341
+
342
+ const batch = batches[batchIndex++];
343
+ let batchSettled = false;
344
+ const resolveBatch = () => {
345
+ if (!active || batchSettled) return;
346
+ batchSettled = true;
347
+ completedDownloadBytes += batch.downloadBytes;
348
+ runNextBatch();
349
+ };
350
+ if (batch.useMultipart) executeMultipartBatch(batch, resolveBatch);
351
+ else executeSingleRangeBatch(batch, resolveBatch);
352
+ };
353
+
354
+ if (typeof cancellationToken?.onCancel === 'function') {
355
+ cancellationToken.onCancel(cancelCurrentOwner);
356
+ } else if (typeof cancellationToken?.once === 'function') {
357
+ cancellationToken.once('cancel', cancelCurrentOwner);
358
+ }
359
+
360
+ return taskOffset => {
361
+ if (started) {
362
+ rejectCurrentOwner(new Error('desktop-update-range-batching-already-started'));
363
+ return;
364
+ }
365
+ started = true;
366
+ if (taskOffset !== 0) {
367
+ rejectCurrentOwner(new Error('desktop-update-range-batching-invalid-start'));
368
+ return;
369
+ }
370
+ runNextBatch();
371
+ };
372
+ };
373
+ }
374
+
375
+ export function installBoundedMultipartRangeBatching() {
376
+ const packageInfo = require('electron-updater/package.json');
377
+ if (packageInfo.version !== SUPPORTED_ELECTRON_UPDATER_VERSION) {
378
+ throw new Error(
379
+ `desktop-update-range-batching-unsupported-electron-updater:${packageInfo.version || 'unknown'}`
380
+ );
381
+ }
382
+
383
+ const multipleRangeDownloader = require('electron-updater/out/differentialDownloader/multipleRangeDownloader');
384
+ if (multipleRangeDownloader.executeTasksUsingMultipleRangeRequests?.[PATCH_MARKER]) return true;
385
+
386
+ const { DataSplitter } = require('electron-updater/out/differentialDownloader/DataSplitter');
387
+ const { CancellationError, safeGetHeader } = require('builder-util-runtime');
388
+ const replacement = createExecutor({
389
+ DataSplitter,
390
+ checkIsRangesSupported: multipleRangeDownloader.checkIsRangesSupported,
391
+ CancellationError,
392
+ safeGetHeader
393
+ });
394
+ Object.defineProperty(replacement, PATCH_MARKER, { value: true });
395
+ multipleRangeDownloader.executeTasksUsingMultipleRangeRequests = replacement;
396
+ return true;
397
+ }
398
+
399
+ export function markUpdaterWithBoundedMultipartRangeBatching(updater) {
400
+ if (!updater) return updater;
401
+ Object.defineProperty(updater, UPDATER_CAPABILITY, { value: true });
402
+ return updater;
403
+ }
404
+
405
+ export function supportsBoundedMultipartRangeBatching(updater) {
406
+ return Boolean(updater?.[UPDATER_CAPABILITY]);
407
+ }
@@ -3,6 +3,11 @@ import { readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { EventEmitter } from 'node:events';
5
5
  import { resolveDesktopUpdateFeed } from './update-feed-resolver.mjs';
6
+ import {
7
+ installBoundedMultipartRangeBatching,
8
+ markUpdaterWithBoundedMultipartRangeBatching,
9
+ supportsBoundedMultipartRangeBatching
10
+ } from './bounded-multipart-range-batching.mjs';
6
11
 
7
12
  const require = createRequire(import.meta.url);
8
13
 
@@ -14,7 +19,22 @@ const MINIMUM_DEADLINE_MS = 10;
14
19
 
15
20
  export function createPlatformProductUpdater(platform = process.platform) {
16
21
  const module = require('electron-updater');
17
- if (platform === 'win32') return new module.NsisUpdater();
22
+ if (platform === 'win32') {
23
+ let boundedMultipartReady = false;
24
+ let boundedMultipartError = null;
25
+ try {
26
+ boundedMultipartReady = installBoundedMultipartRangeBatching();
27
+ } catch (error) {
28
+ boundedMultipartError = error;
29
+ }
30
+ const updater = new module.NsisUpdater();
31
+ if (boundedMultipartReady) return markUpdaterWithBoundedMultipartRangeBatching(updater);
32
+ updater.logger?.warn?.(
33
+ `Bounded differential range batching unavailable; using single-range requests: `
34
+ + `${boundedMultipartError?.message || 'unknown'}`
35
+ );
36
+ return updater;
37
+ }
18
38
  if (platform === 'darwin') return new module.MacUpdater();
19
39
  try {
20
40
  const packageType = String(readFileSync(join(process.resourcesPath, 'package-type'), 'utf8')).trim();
@@ -399,13 +419,14 @@ export function createProductUpdateManager({
399
419
  candidate.autoDownload = false;
400
420
  candidate.autoInstallOnAppQuit = true;
401
421
  if (feed.url) {
402
- // R2 exposes one byte range per read. Keep current clients on simple
403
- // single-range differential requests; the Worker retains bounded
404
- // multipart compatibility for already-installed older versions.
422
+ // Enable multipart reads only for the exact supported electron-updater
423
+ // implementation patched with VuvoDesk's 48-range/32 MiB batch owner.
424
+ // Injected, replaced, or unsupported updater instances stay on the safe
425
+ // single-range path.
405
426
  candidate.setFeedURL({
406
427
  provider: 'generic',
407
428
  url: feed.url,
408
- useMultipleRangeRequest: false
429
+ useMultipleRangeRequest: supportsBoundedMultipartRangeBatching(candidate)
409
430
  });
410
431
  }
411
432
  for (const eventName of forwardedUpdaterEvents) {
package/hub/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/hub",
3
- "version": "0.1.67",
3
+ "version": "0.1.68",
4
4
  "description": "VuvoDesk local Hub API and browser frame bridge",
5
5
  "type": "module",
6
6
  "main": "src/server.js",