@livedesk/hub 0.1.46 → 0.1.48

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.
@@ -98,8 +98,26 @@ async function mountedRoots() {
98
98
  return roots;
99
99
  }
100
100
 
101
- export async function discoverFilesystemRoots() {
102
- const roots = process.platform === 'win32' ? await windowsRoots() : await mountedRoots();
101
+ export async function discoverFilesystemRoots() {
102
+ const isolatedTestRoot = process.env.LIVEDESK_TEST_MODE === '1'
103
+ ? normalizeRootPath(process.env.LIVEDESK_TEST_FILESYSTEM_ROOT)
104
+ : '';
105
+ if (isolatedTestRoot) {
106
+ const rootStat = await fs.stat(isolatedTestRoot);
107
+ if (!rootStat.isDirectory()) {
108
+ throw new Error('LIVEDESK_TEST_FILESYSTEM_ROOT must be a directory.');
109
+ }
110
+ return [{
111
+ path: isolatedTestRoot,
112
+ name: 'Test drive',
113
+ displayPath: isolatedTestRoot,
114
+ type: 'drive',
115
+ driveType: 'fixed',
116
+ ...await statfsCapacity(isolatedTestRoot),
117
+ hasChildren: true
118
+ }];
119
+ }
120
+ const roots = process.platform === 'win32' ? await windowsRoots() : await mountedRoots();
103
121
  if (roots.length > 0) return roots;
104
122
  const fallback = process.platform === 'win32' ? `${process.env.SystemDrive || 'C:'}\\` : os.homedir();
105
123
  return [{
@@ -241,11 +241,11 @@ function resetReconnectCandidate(target) {
241
241
  target.stabilityDeadlineAt = '';
242
242
  }
243
243
 
244
- export function createLiveDeskUpdateManager({
245
- remoteHub,
246
- currentManagerVersion,
247
- currentClientVersion,
248
- excludedDeviceIds = [],
244
+ export function createLiveDeskUpdateManager({
245
+ remoteHub,
246
+ currentManagerVersion,
247
+ currentClientVersion,
248
+ excludedDeviceIds = [],
249
249
  restartSupported = false,
250
250
  requestHubRestart,
251
251
  fetchImpl = globalThis.fetch,
@@ -261,16 +261,16 @@ export function createLiveDeskUpdateManager({
261
261
  let checkPromise = null;
262
262
  let run = null;
263
263
  let checkTimer = null;
264
- let runTimer = null;
265
- const excludedDeviceIdSet = new Set(
266
- (Array.isArray(excludedDeviceIds) ? excludedDeviceIds : [excludedDeviceIds])
267
- .map(value => String(value || '').trim())
268
- .filter(Boolean)
269
- );
270
- const connectedClientDevices = () => connectedDevices(remoteHub)
271
- .filter(device => !excludedDeviceIdSet.has(String(device?.deviceId || '')));
272
- const knownOfflineClientDevices = () => knownOfflineDevices(remoteHub)
273
- .filter(device => !excludedDeviceIdSet.has(String(device?.deviceId || '')));
264
+ let runTimer = null;
265
+ const excludedDeviceIdSet = new Set(
266
+ (Array.isArray(excludedDeviceIds) ? excludedDeviceIds : [excludedDeviceIds])
267
+ .map(value => String(value || '').trim())
268
+ .filter(Boolean)
269
+ );
270
+ const connectedClientDevices = () => connectedDevices(remoteHub)
271
+ .filter(device => !excludedDeviceIdSet.has(String(device?.deviceId || '')));
272
+ const knownOfflineClientDevices = () => knownOfflineDevices(remoteHub)
273
+ .filter(device => !excludedDeviceIdSet.has(String(device?.deviceId || '')));
274
274
  const effectiveClientBatchSize = Math.max(
275
275
  1,
276
276
  Math.min(50, Math.floor(Number(clientBatchSize) || LIVE_DESK_UPDATE_CLIENT_BATCH_SIZE))
@@ -321,7 +321,7 @@ export function createLiveDeskUpdateManager({
321
321
  const getStatus = () => {
322
322
  const managerUpdateAvailable = !!latestRelease
323
323
  && compareVersions(latestRelease.latestManagerVersion, currentManagerVersion) > 0;
324
- const clientDevices = connectedClientDevices();
324
+ const clientDevices = connectedClientDevices();
325
325
  const outdatedClientDevices = latestRelease
326
326
  ? clientDevices.filter(device => deviceNeedsUpdate(
327
327
  device,
@@ -330,7 +330,7 @@ export function createLiveDeskUpdateManager({
330
330
  ))
331
331
  : [];
332
332
  const pendingOfflineClientCount = latestRelease
333
- ? knownOfflineClientDevices().filter(device => deviceNeedsUpdate(
333
+ ? knownOfflineClientDevices().filter(device => deviceNeedsUpdate(
334
334
  device,
335
335
  latestRelease.latestManagerVersion,
336
336
  latestRelease.latestClientVersion
@@ -414,9 +414,9 @@ export function createLiveDeskUpdateManager({
414
414
  if (!run || run.state !== 'failed') return [];
415
415
  const currentTime = now();
416
416
  const devices = new Map(
417
- remoteHub.listDevices({ includeDataUrl: false })
418
- .filter(device => !excludedDeviceIdSet.has(String(device?.deviceId || '')))
419
- .map(device => [String(device.deviceId), device])
417
+ remoteHub.listDevices({ includeDataUrl: false })
418
+ .filter(device => !excludedDeviceIdSet.has(String(device?.deviceId || '')))
419
+ .map(device => [String(device.deviceId), device])
420
420
  );
421
421
  return run.targets.filter(target => {
422
422
  if (target.state !== 'failed' || !target.dispatchedAt) return false;
@@ -523,7 +523,7 @@ export function createLiveDeskUpdateManager({
523
523
  const activeCount = run.targets.filter(target => target.state === 'waiting').length;
524
524
  let availableSlots = Math.max(0, run.batchSize - activeCount);
525
525
  if (availableSlots <= 0) return true;
526
- const devices = new Map(connectedClientDevices().map(device => [String(device.deviceId), device]));
526
+ const devices = new Map(connectedClientDevices().map(device => [String(device.deviceId), device]));
527
527
  for (const target of run.targets) {
528
528
  if (availableSlots <= 0) break;
529
529
  if (!['queued', 'pending-offline'].includes(target.state)) continue;
@@ -562,7 +562,7 @@ export function createLiveDeskUpdateManager({
562
562
  failRun(`Timed out waiting for ${unfinishedCount} client(s) to finish the rollout.`);
563
563
  return;
564
564
  }
565
- const devices = new Map(connectedClientDevices().map(device => [String(device.deviceId), device]));
565
+ const devices = new Map(connectedClientDevices().map(device => [String(device.deviceId), device]));
566
566
  for (const target of run.targets) {
567
567
  if (target.state === 'failed' || target.state === 'completed') continue;
568
568
  const device = devices.get(target.deviceId);
@@ -689,7 +689,7 @@ export function createLiveDeskUpdateManager({
689
689
  if (!release) return { ok: false, error: checkError || 'LiveDesk update check failed.', ...getStatus() };
690
690
  const managerNeedsUpdate = compareVersions(release.latestManagerVersion, currentManagerVersion) > 0;
691
691
  const clientPackageNeedsUpdate = compareVersions(release.latestClientVersion, currentClientVersion) > 0;
692
- const connected = connectedClientDevices();
692
+ const connected = connectedClientDevices();
693
693
  const outdatedClients = connected.filter(device => deviceNeedsUpdate(
694
694
  device,
695
695
  release.latestManagerVersion,
@@ -790,7 +790,7 @@ export function createLiveDeskUpdateManager({
790
790
  if (target.method === 'dedicated'
791
791
  && target.bootstrapRetryCount === 0
792
792
  && isDedicatedBootstrapFailure(failure)) {
793
- const retryDevice = connectedClientDevices()
793
+ const retryDevice = connectedClientDevices()
794
794
  .find(device => String(device.deviceId || '') === target.deviceId);
795
795
  const remainingMs = Date.parse(target.deadlineAt || '') - now();
796
796
  if (retryDevice?.connected === true && remainingMs >= 10_000) {
@@ -0,0 +1,482 @@
1
+ const MIB = 1024 * 1024;
2
+
3
+ export const REMOTE_CLIPBOARD_PROTOCOL = 'livedesk.clipboard.v1';
4
+
5
+ export const REMOTE_CLIPBOARD_LIMITS = Object.freeze({
6
+ maxTextBytes: 1 * MIB,
7
+ maxImageBytes: 24 * MIB,
8
+ maxTotalBytes: 256 * MIB,
9
+ maxFiles: 24,
10
+ // 384,000 raw bytes encode to 512,000 base64 characters, leaving bounded
11
+ // room for the command envelope below Relay's 512 KiB JSON-message limit.
12
+ maxChunkBytes: 384_000,
13
+ maxOperationsPerDevice: 2,
14
+ operationIdleTimeoutMs: 2 * 60 * 1000,
15
+ commandTimeoutMs: 15 * 1000
16
+ });
17
+
18
+ export const REMOTE_CLIPBOARD_INPUT_ACTIONS = Object.freeze(new Set([
19
+ 'clipboard.prepare',
20
+ 'clipboard.copy',
21
+ 'clipboard.paste'
22
+ ]));
23
+
24
+ export const REMOTE_CLIPBOARD_FILE_ACTIONS = Object.freeze(new Set([
25
+ 'clipboard.copy.begin',
26
+ 'clipboard.copy.chunk',
27
+ 'clipboard.copy.end',
28
+ 'clipboard.paste.begin',
29
+ 'clipboard.paste.chunk',
30
+ 'clipboard.paste.cancel',
31
+ 'clipboard.operation.status'
32
+ ]));
33
+
34
+ const ACTION_ALIASES = new Map([
35
+ ['prepare', 'clipboard.prepare'],
36
+ ['copy', 'clipboard.copy'],
37
+ ['paste', 'clipboard.paste'],
38
+ ['copy.begin', 'clipboard.copy.begin'],
39
+ ['copy.chunk', 'clipboard.copy.chunk'],
40
+ ['copy.end', 'clipboard.copy.end'],
41
+ ['paste.begin', 'clipboard.paste.begin'],
42
+ ['paste.chunk', 'clipboard.paste.chunk'],
43
+ ['paste.cancel', 'clipboard.paste.cancel'],
44
+ ['operation.status', 'clipboard.operation.status']
45
+ ]);
46
+
47
+ const SAFE_STATUS_VALUES = new Set([
48
+ 'preparing',
49
+ 'copying',
50
+ 'receiving',
51
+ 'ready',
52
+ 'completed',
53
+ 'cancelled',
54
+ 'cleaned',
55
+ 'failed',
56
+ 'expired',
57
+ 'not-found',
58
+ 'already-ended',
59
+ 'complete',
60
+ 'applied',
61
+ 'pasted'
62
+ ]);
63
+
64
+ export class RemoteClipboardContractError extends Error {
65
+ constructor(code, status = 400) {
66
+ super(code);
67
+ this.name = 'RemoteClipboardContractError';
68
+ this.code = code;
69
+ this.status = status;
70
+ }
71
+ }
72
+
73
+ function fail(code, status = 400) {
74
+ throw new RemoteClipboardContractError(code, status);
75
+ }
76
+
77
+ function boundedString(value, maxLength, code, { required = false } = {}) {
78
+ const text = String(value ?? '').trim();
79
+ if ((required && !text) || text.length > maxLength) {
80
+ fail(code);
81
+ }
82
+ return text;
83
+ }
84
+
85
+ function exactInteger(value, { min = 0, max = Number.MAX_SAFE_INTEGER, code }) {
86
+ const number = Number(value);
87
+ if (!Number.isSafeInteger(number) || number < min || number > max) {
88
+ fail(code);
89
+ }
90
+ return number;
91
+ }
92
+
93
+ function optionalInteger(value, options) {
94
+ if (value === undefined || value === null || value === '') {
95
+ return undefined;
96
+ }
97
+ return exactInteger(value, options);
98
+ }
99
+
100
+ function normalizeContentKind(value) {
101
+ const kind = String(value || '').trim().toLowerCase();
102
+ if (!['text', 'image', 'files'].includes(kind)) {
103
+ fail('clipboard-manifest-kind-invalid');
104
+ }
105
+ return kind;
106
+ }
107
+
108
+ function normalizeItemKind(value) {
109
+ const kind = String(value || '').trim().toLowerCase();
110
+ if (!['text', 'image', 'file'].includes(kind)) {
111
+ fail('clipboard-manifest-item-kind-invalid');
112
+ }
113
+ return kind;
114
+ }
115
+
116
+ function normalizeMimeType(value, itemKind) {
117
+ const fallback = itemKind === 'text'
118
+ ? 'text/plain;charset=utf-8'
119
+ : itemKind === 'image'
120
+ ? 'image/png'
121
+ : 'application/octet-stream';
122
+ const mimeType = boundedString(value || fallback, 160, 'clipboard-manifest-mime-invalid');
123
+ if (itemKind === 'text' && !mimeType.toLowerCase().startsWith('text/plain')) {
124
+ fail('clipboard-text-mime-required');
125
+ }
126
+ if (itemKind === 'image' && mimeType.toLowerCase() !== 'image/png') {
127
+ fail('clipboard-image-png-required');
128
+ }
129
+ return mimeType;
130
+ }
131
+
132
+ export function normalizeRemoteClipboardOperationId(value) {
133
+ const operationId = boundedString(value, 128, 'clipboard-operation-id-invalid', { required: true });
134
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(operationId)) {
135
+ fail('clipboard-operation-id-invalid');
136
+ }
137
+ return operationId;
138
+ }
139
+
140
+ export function normalizeRemoteClipboardAction(value) {
141
+ const requested = String(value || '').trim().toLowerCase();
142
+ const action = ACTION_ALIASES.get(requested) || requested;
143
+ if (!REMOTE_CLIPBOARD_INPUT_ACTIONS.has(action) && !REMOTE_CLIPBOARD_FILE_ACTIONS.has(action)) {
144
+ fail('clipboard-action-invalid');
145
+ }
146
+ return action;
147
+ }
148
+
149
+ export function normalizeRemoteClipboardManifest(value, expectedOperationId = '') {
150
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
151
+ fail('clipboard-manifest-required');
152
+ }
153
+ const operationId = normalizeRemoteClipboardOperationId(
154
+ value.operationId || value.clipboardOperationId || expectedOperationId);
155
+ if (expectedOperationId && operationId !== expectedOperationId) {
156
+ fail('clipboard-operation-id-mismatch');
157
+ }
158
+ const contentKind = normalizeContentKind(value.contentKind || value.kind);
159
+ const rawItems = Array.isArray(value.items) ? value.items : [];
160
+ if (rawItems.length === 0 || rawItems.length > REMOTE_CLIPBOARD_LIMITS.maxFiles) {
161
+ fail('clipboard-manifest-item-count-invalid');
162
+ }
163
+ const expectedItemKind = contentKind === 'files' ? 'file' : contentKind;
164
+ if (contentKind !== 'files' && rawItems.length !== 1) {
165
+ fail('clipboard-single-item-required');
166
+ }
167
+
168
+ let totalBytes = 0;
169
+ const items = rawItems.map((rawItem, itemIndex) => {
170
+ const item = rawItem && typeof rawItem === 'object' && !Array.isArray(rawItem) ? rawItem : {};
171
+ const normalizedIndex = exactInteger(item.itemIndex, {
172
+ min: 0,
173
+ max: REMOTE_CLIPBOARD_LIMITS.maxFiles - 1,
174
+ code: 'clipboard-manifest-item-index-invalid'
175
+ });
176
+ if (normalizedIndex !== itemIndex) {
177
+ fail('clipboard-manifest-item-index-invalid');
178
+ }
179
+ const kind = normalizeItemKind(item.kind);
180
+ if (kind !== expectedItemKind) {
181
+ fail('clipboard-manifest-item-kind-mismatch');
182
+ }
183
+ const size = exactInteger(item.size, {
184
+ min: 0,
185
+ max: REMOTE_CLIPBOARD_LIMITS.maxTotalBytes,
186
+ code: 'clipboard-manifest-item-size-invalid'
187
+ });
188
+ if (kind !== 'file' && size === 0) {
189
+ fail('clipboard-manifest-item-size-invalid');
190
+ }
191
+ if (kind === 'text' && size > REMOTE_CLIPBOARD_LIMITS.maxTextBytes) {
192
+ fail('clipboard-text-too-large', 413);
193
+ }
194
+ if (kind === 'image' && size > REMOTE_CLIPBOARD_LIMITS.maxImageBytes) {
195
+ fail('clipboard-image-too-large', 413);
196
+ }
197
+ totalBytes += size;
198
+ if (totalBytes > REMOTE_CLIPBOARD_LIMITS.maxTotalBytes) {
199
+ fail('clipboard-total-too-large', 413);
200
+ }
201
+ const storageName = boundedString(
202
+ item.storageName,
203
+ 64,
204
+ 'clipboard-manifest-storage-name-invalid',
205
+ { required: true });
206
+ if (storageName !== `item-${itemIndex}.bin`) {
207
+ fail('clipboard-manifest-storage-name-invalid');
208
+ }
209
+ const sha256 = boundedString(item.sha256, 64, 'clipboard-manifest-sha256-invalid', { required: true }).toLowerCase();
210
+ if (!/^[a-f0-9]{64}$/.test(sha256)) {
211
+ fail('clipboard-manifest-sha256-invalid');
212
+ }
213
+ const name = boundedString(
214
+ item.name || (kind === 'text' ? 'clipboard.txt' : kind === 'image' ? 'clipboard.png' : ''),
215
+ 240,
216
+ 'clipboard-manifest-name-invalid',
217
+ { required: true });
218
+ if (/[/\\\u0000-\u001f]/.test(name) || name === '.' || name === '..') {
219
+ fail('clipboard-manifest-name-invalid');
220
+ }
221
+ return {
222
+ itemIndex,
223
+ kind,
224
+ name,
225
+ mimeType: normalizeMimeType(item.mimeType, kind),
226
+ size,
227
+ lastModified: optionalInteger(item.lastModified, {
228
+ min: 0,
229
+ max: 8_640_000_000_000_000,
230
+ code: 'clipboard-manifest-last-modified-invalid'
231
+ }) || 0,
232
+ sha256,
233
+ storageName
234
+ };
235
+ });
236
+
237
+ const declaredTotalBytes = optionalInteger(value.totalBytes, {
238
+ min: 0,
239
+ max: REMOTE_CLIPBOARD_LIMITS.maxTotalBytes,
240
+ code: 'clipboard-manifest-total-invalid'
241
+ });
242
+ if (declaredTotalBytes !== undefined && declaredTotalBytes !== totalBytes) {
243
+ fail('clipboard-manifest-total-mismatch');
244
+ }
245
+ return { operationId, contentKind, totalBytes, items };
246
+ }
247
+
248
+ export function normalizeRemoteClipboardBase64(value, maxBytes = REMOTE_CLIPBOARD_LIMITS.maxChunkBytes) {
249
+ const encoded = String(value || '').trim();
250
+ if (encoded.length > Math.ceil(maxBytes / 3) * 4 + 4 || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) {
251
+ fail('clipboard-chunk-base64-invalid');
252
+ }
253
+ const bytes = Buffer.from(encoded, 'base64');
254
+ if (bytes.length > maxBytes) {
255
+ fail('clipboard-chunk-too-large', 413);
256
+ }
257
+ if (bytes.toString('base64') !== encoded) {
258
+ fail('clipboard-chunk-base64-invalid');
259
+ }
260
+ return { dataBase64: encoded, byteLength: bytes.length };
261
+ }
262
+
263
+ function normalizeBinding(body) {
264
+ const binding = body?.binding && typeof body.binding === 'object' && !Array.isArray(body.binding)
265
+ ? body.binding
266
+ : {};
267
+ return {
268
+ controlSessionId: boundedString(
269
+ binding.sessionId || binding.controlSessionId || body.controlSessionId || body.sessionId,
270
+ 160,
271
+ 'clipboard-control-session-required',
272
+ { required: true }),
273
+ controlCommandId: boundedString(
274
+ binding.commandId || binding.controlCommandId || body.controlCommandId || body.commandId,
275
+ 128,
276
+ 'clipboard-control-command-required',
277
+ { required: true }),
278
+ captureGeneration: exactInteger(
279
+ binding.captureGeneration ?? body.captureGeneration,
280
+ { min: 1, max: Number.MAX_SAFE_INTEGER, code: 'clipboard-capture-generation-required' }),
281
+ monitorIndex: exactInteger(
282
+ binding.monitorIndex ?? body.monitorIndex,
283
+ { min: 0, max: 63, code: 'clipboard-monitor-index-required' })
284
+ };
285
+ }
286
+
287
+ function normalizeClipboardPayload(action, rawPayload, operationId) {
288
+ const payload = rawPayload && typeof rawPayload === 'object' && !Array.isArray(rawPayload)
289
+ ? rawPayload
290
+ : {};
291
+ if (action === 'clipboard.paste.begin') {
292
+ return { manifest: normalizeRemoteClipboardManifest(payload.manifest || payload, operationId) };
293
+ }
294
+ if (action === 'clipboard.copy.chunk') {
295
+ return {
296
+ itemIndex: exactInteger(payload.itemIndex, {
297
+ min: 0,
298
+ max: REMOTE_CLIPBOARD_LIMITS.maxFiles - 1,
299
+ code: 'clipboard-item-index-invalid'
300
+ }),
301
+ offset: exactInteger(payload.offset, {
302
+ min: 0,
303
+ max: REMOTE_CLIPBOARD_LIMITS.maxTotalBytes,
304
+ code: 'clipboard-chunk-offset-invalid'
305
+ }),
306
+ maxBytes: exactInteger(payload.maxBytes ?? REMOTE_CLIPBOARD_LIMITS.maxChunkBytes, {
307
+ min: 1,
308
+ max: REMOTE_CLIPBOARD_LIMITS.maxChunkBytes,
309
+ code: 'clipboard-chunk-max-bytes-invalid'
310
+ })
311
+ };
312
+ }
313
+ if (action === 'clipboard.paste.chunk') {
314
+ const chunk = normalizeRemoteClipboardBase64(payload.dataBase64);
315
+ return {
316
+ itemIndex: exactInteger(payload.itemIndex, {
317
+ min: 0,
318
+ max: REMOTE_CLIPBOARD_LIMITS.maxFiles - 1,
319
+ code: 'clipboard-item-index-invalid'
320
+ }),
321
+ offset: exactInteger(payload.offset, {
322
+ min: 0,
323
+ max: REMOTE_CLIPBOARD_LIMITS.maxTotalBytes,
324
+ code: 'clipboard-chunk-offset-invalid'
325
+ }),
326
+ dataBase64: chunk.dataBase64,
327
+ final: payload.final === true
328
+ };
329
+ }
330
+ const normalized = {};
331
+ const manifestValue = payload.manifest;
332
+ if (manifestValue) {
333
+ normalized.manifest = normalizeRemoteClipboardManifest(manifestValue, operationId);
334
+ }
335
+ const requestedKind = payload.contentKind || payload.kind;
336
+ if (requestedKind) {
337
+ normalized.contentKind = normalizeContentKind(requestedKind);
338
+ }
339
+ return normalized;
340
+ }
341
+
342
+ export function normalizeRemoteClipboardRequest(value) {
343
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
344
+ fail('clipboard-request-invalid');
345
+ }
346
+ const action = normalizeRemoteClipboardAction(value.action || value.type);
347
+ const operationId = normalizeRemoteClipboardOperationId(
348
+ value.operationId || value.clipboardOperationId || value.op?.operationId || value.op);
349
+ const hubConnectionId = boundedString(
350
+ value.hubConnectionId || value.binding?.hubConnectionId,
351
+ 128,
352
+ 'clipboard-input-owner-required',
353
+ { required: true });
354
+ const clipboardDirection = action.includes('.paste')
355
+ ? 'paste'
356
+ : action.includes('.copy')
357
+ ? 'copy'
358
+ : String(value.clipboardDirection || value.payload?.clipboardDirection || '').trim().toLowerCase();
359
+ if (action === 'clipboard.prepare' && !['copy', 'paste'].includes(clipboardDirection)) {
360
+ fail('clipboard-direction-required');
361
+ }
362
+ return {
363
+ action,
364
+ operationId,
365
+ hubConnectionId,
366
+ clipboardDirection,
367
+ binding: normalizeBinding(value),
368
+ payload: normalizeClipboardPayload(action, value.payload, operationId)
369
+ };
370
+ }
371
+
372
+ export function remoteClipboardContentNeedsFileTransfer(contentKind) {
373
+ return contentKind === 'image' || contentKind === 'files';
374
+ }
375
+
376
+ function safeStatus(value) {
377
+ const status = String(value || '').trim().toLowerCase();
378
+ return SAFE_STATUS_VALUES.has(status) ? status : '';
379
+ }
380
+
381
+ export function normalizeRemoteClipboardCommandResult(action, operationId, value) {
382
+ const result = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
383
+ const returnedOperationId = result.operationId || result.clipboardOperationId;
384
+ if (returnedOperationId && normalizeRemoteClipboardOperationId(returnedOperationId) !== operationId) {
385
+ fail('clipboard-operation-id-mismatch', 409);
386
+ }
387
+ const response = {
388
+ ok: result.ok !== false,
389
+ action,
390
+ operationId,
391
+ status: safeStatus(result.status) || (result.ok === false ? 'failed' : '')
392
+ };
393
+ if (action === 'clipboard.copy.begin') {
394
+ response.manifest = normalizeRemoteClipboardManifest(result.manifest, operationId);
395
+ response.kind = response.manifest.contentKind;
396
+ response.status ||= 'ready';
397
+ return response;
398
+ }
399
+ if (action === 'clipboard.copy.chunk') {
400
+ const chunk = normalizeRemoteClipboardBase64(result.dataBase64);
401
+ const itemIndex = exactInteger(result.itemIndex, {
402
+ min: 0,
403
+ max: REMOTE_CLIPBOARD_LIMITS.maxFiles - 1,
404
+ code: 'clipboard-item-index-invalid'
405
+ });
406
+ const offset = exactInteger(result.offset, {
407
+ min: 0,
408
+ max: REMOTE_CLIPBOARD_LIMITS.maxTotalBytes,
409
+ code: 'clipboard-chunk-offset-invalid'
410
+ });
411
+ const bytes = exactInteger(result.bytes ?? chunk.byteLength, {
412
+ min: 0,
413
+ max: REMOTE_CLIPBOARD_LIMITS.maxChunkBytes,
414
+ code: 'clipboard-chunk-byte-count-invalid'
415
+ });
416
+ if (bytes !== chunk.byteLength) {
417
+ fail('clipboard-chunk-byte-count-mismatch');
418
+ }
419
+ return {
420
+ ok: result.ok !== false,
421
+ action,
422
+ operationId,
423
+ itemIndex,
424
+ offset,
425
+ bytes,
426
+ totalBytes: exactInteger(result.totalBytes, {
427
+ min: 0,
428
+ max: REMOTE_CLIPBOARD_LIMITS.maxTotalBytes,
429
+ code: 'clipboard-chunk-total-invalid'
430
+ }),
431
+ final: result.final === true,
432
+ dataBase64: chunk.dataBase64
433
+ };
434
+ }
435
+ if (action === 'clipboard.paste.chunk') {
436
+ return {
437
+ ok: result.ok !== false,
438
+ action,
439
+ operationId,
440
+ status: safeStatus(result.status) || '',
441
+ itemIndex: exactInteger(result.itemIndex, {
442
+ min: 0,
443
+ max: REMOTE_CLIPBOARD_LIMITS.maxFiles - 1,
444
+ code: 'clipboard-item-index-invalid'
445
+ }),
446
+ offset: exactInteger(result.offset, {
447
+ min: 0,
448
+ max: REMOTE_CLIPBOARD_LIMITS.maxTotalBytes,
449
+ code: 'clipboard-chunk-offset-invalid'
450
+ }),
451
+ bytes: exactInteger(result.bytes, {
452
+ min: 0,
453
+ max: REMOTE_CLIPBOARD_LIMITS.maxChunkBytes,
454
+ code: 'clipboard-chunk-byte-count-invalid'
455
+ }),
456
+ totalBytes: exactInteger(result.totalBytes, {
457
+ min: 0,
458
+ max: REMOTE_CLIPBOARD_LIMITS.maxTotalBytes,
459
+ code: 'clipboard-chunk-total-invalid'
460
+ }),
461
+ final: result.final === true
462
+ };
463
+ }
464
+ if (result.manifest) {
465
+ response.manifest = normalizeRemoteClipboardManifest(result.manifest, operationId);
466
+ response.kind = response.manifest.contentKind;
467
+ } else {
468
+ const kind = result.contentKind || result.kind;
469
+ if (kind) response.kind = normalizeContentKind(kind);
470
+ }
471
+ for (const key of ['receivedBytes', 'totalBytes']) {
472
+ if (result[key] !== undefined) {
473
+ response[key] = exactInteger(result[key], {
474
+ min: 0,
475
+ max: REMOTE_CLIPBOARD_LIMITS.maxTotalBytes,
476
+ code: `clipboard-${key}-invalid`
477
+ });
478
+ }
479
+ }
480
+ if (result.ready !== undefined) response.ready = result.ready === true;
481
+ return response;
482
+ }