livedesk 0.1.588 → 0.1.590

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.249",
3
+ "version": "0.1.250",
4
4
  "description": "LiveDesk 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.445",
46
- "@livedesk/fast-osx-arm64": "0.1.445",
47
- "@livedesk/fast-osx-x64": "0.1.445",
48
- "@livedesk/fast-win-x64": "0.1.445"
45
+ "@livedesk/fast-linux-x64": "0.1.446",
46
+ "@livedesk/fast-osx-arm64": "0.1.446",
47
+ "@livedesk/fast-osx-x64": "0.1.446",
48
+ "@livedesk/fast-win-x64": "0.1.446"
49
49
  },
50
50
  "publishConfig": {
51
51
  "access": "public"
@@ -1,7 +1,16 @@
1
1
  export const DESKTOP_AUTH_REFRESH_SKEW_SECONDS = 5 * 60;
2
2
  export const DESKTOP_AUTH_RETRY_MS = 15_000;
3
3
  export const DESKTOP_AUTH_MINIMUM_TIMER_MS = 30_000;
4
- export const DESKTOP_AUTH_INVALID_CONFIRMATIONS = 3;
4
+ export const DESKTOP_AUTH_INVALID_CONFIRMATIONS = 3;
5
+
6
+ const DEFINITIVE_REFRESH_REJECTION_CODES = new Set([
7
+ 'invalid_grant',
8
+ 'refresh_token_already_used',
9
+ 'refresh_token_not_found',
10
+ 'session_expired',
11
+ 'session_not_found',
12
+ 'user_not_found'
13
+ ]);
5
14
 
6
15
  export function desktopSessionNeedsRefresh(
7
16
  session,
@@ -35,10 +44,20 @@ export function desktopSessionRefreshDelayMs(
35
44
  );
36
45
  }
37
46
 
38
- export function isPermanentDesktopAuthRefreshFailure(error) {
39
- const status = Number(error?.authStatus || 0);
40
- return status === 400 || status === 401 || status === 403;
41
- }
47
+ export function isPermanentDesktopAuthRefreshFailure(error) {
48
+ const status = Number(error?.authStatus || 0);
49
+ if (status !== 400 && status !== 401 && status !== 403) return false;
50
+
51
+ // Status alone is not logout authority. A captive portal, provider edge,
52
+ // corporate proxy, or temporarily inconsistent auth replica can all return
53
+ // 403 while the saved refresh token remains valid. Supabase provides a
54
+ // stable refresh/session error code for an actual credential rejection; old
55
+ // GoTrue releases are covered by the narrow legacy message fallback.
56
+ const code = String(error?.authCode || '').trim().toLowerCase();
57
+ if (DEFINITIVE_REFRESH_REJECTION_CODES.has(code)) return true;
58
+ const message = String(error?.message || '');
59
+ return /invalid refresh token|refresh token (?:was )?(?:not found|already used)/i.test(message);
60
+ }
42
61
 
43
62
  export function selectFreshestDesktopSession(currentSession, incomingSession) {
44
63
  if (!currentSession) return incomingSession || null;
@@ -0,0 +1,382 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import {
4
+ mkdir,
5
+ mkdtemp,
6
+ open,
7
+ readdir,
8
+ readFile,
9
+ rm,
10
+ stat,
11
+ writeFile
12
+ } from 'node:fs/promises';
13
+ import { basename, join, resolve, sep } from 'node:path';
14
+
15
+ export const DESKTOP_CLIPBOARD_CHUNK_BYTES = 512 * 1024;
16
+ export const DESKTOP_CLIPBOARD_TEXT_BYTES = 1024 * 1024;
17
+ export const DESKTOP_CLIPBOARD_PNG_BYTES = 24 * 1024 * 1024;
18
+ export const DESKTOP_CLIPBOARD_TOTAL_BYTES = 256 * 1024 * 1024;
19
+ export const DESKTOP_CLIPBOARD_MAX_ITEMS = 24;
20
+
21
+ const DESKTOP_CLIPBOARD_MAX_BASE64_CHARS = Math.ceil(DESKTOP_CLIPBOARD_CHUNK_BYTES / 3) * 4;
22
+ const CANONICAL_BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
23
+
24
+ const HELPER_TIMEOUT_MS = 30_000;
25
+ const HELPER_OUTPUT_LIMIT = 32 * 1024;
26
+ const SNAPSHOT_TTL_MS = 10 * 60 * 1000;
27
+
28
+ function safeOperationId(value) {
29
+ return String(value || '').replace(/[^a-zA-Z0-9_.:-]/g, '-').slice(0, 128);
30
+ }
31
+
32
+ function safeStorageName(value, index) {
33
+ const name = String(value || `item-${index}.bin`);
34
+ if (name !== basename(name) || !/^item-\d+\.bin$/.test(name)) {
35
+ throw new Error('clipboard-storage-name-invalid');
36
+ }
37
+ return name;
38
+ }
39
+
40
+ function normalizeManifest(value, expectedOperationId = '') {
41
+ const source = value && typeof value === 'object' ? value : {};
42
+ const operationId = safeOperationId(source.operationId || expectedOperationId);
43
+ const contentKind = String(source.contentKind || '').toLowerCase();
44
+ if (!operationId || !['text', 'image', 'files'].includes(contentKind)) {
45
+ throw new Error('clipboard-manifest-invalid');
46
+ }
47
+ const rawItems = Array.isArray(source.items) ? source.items : [];
48
+ if (rawItems.length < 1 || rawItems.length > DESKTOP_CLIPBOARD_MAX_ITEMS) {
49
+ throw new Error('clipboard-item-count-invalid');
50
+ }
51
+ const items = rawItems.map((item, index) => {
52
+ const itemIndex = Number(item?.itemIndex);
53
+ const kind = String(item?.kind || '').toLowerCase();
54
+ const size = Number(item?.size);
55
+ if (!Number.isInteger(itemIndex) || itemIndex !== index
56
+ || !['text', 'image', 'file'].includes(kind)
57
+ || !Number.isSafeInteger(size) || size < 0
58
+ || (kind !== 'file' && size === 0)) {
59
+ throw new Error('clipboard-item-invalid');
60
+ }
61
+ const expectedKind = contentKind === 'files' ? 'file' : contentKind;
62
+ if (kind !== expectedKind) {
63
+ throw new Error('clipboard-item-kind-mismatch');
64
+ }
65
+ return {
66
+ itemIndex,
67
+ kind,
68
+ name: String(item?.name || '').replace(/[\0\r\n\t]/g, ' ').trim().slice(0, 240),
69
+ mimeType: String(item?.mimeType || '').slice(0, 160),
70
+ size,
71
+ lastModified: Math.max(0, Number(item?.lastModified || 0) || 0),
72
+ sha256: String(item?.sha256 || '').toLowerCase(),
73
+ storageName: safeStorageName(item?.storageName, index)
74
+ };
75
+ });
76
+ if (items.some(item => !/^[a-f0-9]{64}$/.test(item.sha256))) {
77
+ throw new Error('clipboard-item-hash-invalid');
78
+ }
79
+ const totalBytes = items.reduce((sum, item) => sum + item.size, 0);
80
+ if (totalBytes !== Number(source.totalBytes)
81
+ || totalBytes > DESKTOP_CLIPBOARD_TOTAL_BYTES
82
+ || (contentKind === 'text' && totalBytes > DESKTOP_CLIPBOARD_TEXT_BYTES)
83
+ || (contentKind === 'image' && totalBytes > DESKTOP_CLIPBOARD_PNG_BYTES)) {
84
+ throw new Error('clipboard-size-limit');
85
+ }
86
+ return { operationId, contentKind, totalBytes, items };
87
+ }
88
+
89
+ function appendBounded(previous, chunk) {
90
+ const next = previous + String(chunk || '');
91
+ return next.length <= HELPER_OUTPUT_LIMIT ? next : next.slice(-HELPER_OUTPUT_LIMIT);
92
+ }
93
+
94
+ async function runHelper(helperPath, args, { spawnImpl = spawn, timeoutMs = HELPER_TIMEOUT_MS } = {}) {
95
+ if (!helperPath) throw new Error('clipboard-helper-unavailable');
96
+ return await new Promise((resolvePromise, rejectPromise) => {
97
+ const child = spawnImpl(helperPath, args, {
98
+ stdio: ['ignore', 'pipe', 'pipe'],
99
+ windowsHide: true,
100
+ shell: false
101
+ });
102
+ let stdout = '';
103
+ let stderr = '';
104
+ let settled = false;
105
+ let timer = null;
106
+ const finish = (error, result) => {
107
+ if (settled) return;
108
+ settled = true;
109
+ if (timer) clearTimeout(timer);
110
+ if (error) rejectPromise(error);
111
+ else resolvePromise(result);
112
+ };
113
+ child.stdout?.on('data', chunk => { stdout = appendBounded(stdout, chunk); });
114
+ child.stderr?.on('data', chunk => { stderr = appendBounded(stderr, chunk); });
115
+ child.once('error', error => finish(error));
116
+ child.once('exit', (code, signal) => {
117
+ if (code === 0) finish(null, { stdout, stderr });
118
+ else finish(new Error(`clipboard-helper-failed:${code ?? signal ?? 'unknown'}:${stderr.trim()}`));
119
+ });
120
+ timer = setTimeout(() => {
121
+ try { child.kill('SIGKILL'); } catch { /* exact helper already exited */ }
122
+ finish(new Error('clipboard-helper-timeout'));
123
+ }, timeoutMs);
124
+ timer.unref?.();
125
+ });
126
+ }
127
+
128
+ function assertOwnedStage(root, stagePath) {
129
+ const normalizedRoot = resolve(root) + sep;
130
+ const normalizedStage = resolve(stagePath) + sep;
131
+ if (!normalizedStage.startsWith(normalizedRoot)) {
132
+ throw new Error('clipboard-stage-owner-invalid');
133
+ }
134
+ }
135
+
136
+ export function createDesktopClipboardOwner({
137
+ rootDir,
138
+ helperPath,
139
+ spawnImpl = spawn,
140
+ now = () => Date.now()
141
+ }) {
142
+ if (!rootDir) throw new Error('clipboard-root-required');
143
+ const readOwners = new Map();
144
+ const writeOwners = new Map();
145
+ const retainedFileStages = [];
146
+ let initializePromise = null;
147
+
148
+ const initializeRoot = async () => {
149
+ await mkdir(rootDir, { recursive: true });
150
+ const entries = await readdir(rootDir, { withFileTypes: true });
151
+ const oldWriteStages = [];
152
+ for (const entry of entries) {
153
+ if (!entry.isDirectory() || !/^(read|write)-/.test(entry.name)) continue;
154
+ const stagePath = join(rootDir, entry.name);
155
+ if (entry.name.startsWith('read-')) {
156
+ await rm(stagePath, { recursive: true, force: true });
157
+ continue;
158
+ }
159
+ const stageStat = await stat(stagePath);
160
+ oldWriteStages.push({ stagePath, mtimeMs: stageStat.mtimeMs });
161
+ }
162
+ oldWriteStages.sort((left, right) => right.mtimeMs - left.mtimeMs);
163
+ for (const [index, owner] of oldWriteStages.entries()) {
164
+ if (index < 2) retainedFileStages.push(owner);
165
+ else await rm(owner.stagePath, { recursive: true, force: true });
166
+ }
167
+ };
168
+
169
+ const ensureRoot = () => {
170
+ if (!initializePromise) initializePromise = initializeRoot();
171
+ return initializePromise;
172
+ };
173
+
174
+ const createStage = async prefix => {
175
+ await ensureRoot();
176
+ return await mkdtemp(join(rootDir, `${prefix}-`));
177
+ };
178
+
179
+ const removeStage = async stagePath => {
180
+ if (!stagePath) return;
181
+ assertOwnedStage(rootDir, stagePath);
182
+ await rm(stagePath, { recursive: true, force: true });
183
+ };
184
+
185
+ const sweepExpired = async () => {
186
+ const deadline = now() - SNAPSHOT_TTL_MS;
187
+ for (const owners of [readOwners, writeOwners]) {
188
+ for (const [operationId, owner] of owners) {
189
+ if (owner.touchedAt > deadline) continue;
190
+ owners.delete(operationId);
191
+ await removeStage(owner.stagePath);
192
+ }
193
+ }
194
+ };
195
+
196
+ const readSnapshot = async operationValue => {
197
+ await sweepExpired();
198
+ const operationId = safeOperationId(operationValue);
199
+ if (!operationId) throw new Error('clipboard-operation-id-required');
200
+ const previous = readOwners.get(operationId);
201
+ if (previous) {
202
+ previous.touchedAt = now();
203
+ return previous.manifest;
204
+ }
205
+ const stagePath = await createStage('read');
206
+ try {
207
+ await runHelper(helperPath, ['--clipboard-helper', 'read', stagePath, operationId], { spawnImpl });
208
+ const manifest = normalizeManifest(
209
+ JSON.parse(await readFile(join(stagePath, 'manifest.json'), 'utf8')),
210
+ operationId);
211
+ if (manifest.operationId !== operationId) throw new Error('clipboard-operation-mismatch');
212
+ for (const item of manifest.items) {
213
+ const payloadStat = await stat(join(stagePath, item.storageName));
214
+ if (!payloadStat.isFile() || payloadStat.size !== item.size) {
215
+ throw new Error('clipboard-helper-payload-invalid');
216
+ }
217
+ }
218
+ readOwners.set(operationId, { operationId, stagePath, manifest, touchedAt: now() });
219
+ return manifest;
220
+ } catch (error) {
221
+ await removeStage(stagePath);
222
+ throw error;
223
+ }
224
+ };
225
+
226
+ const readChunk = async ({ operationId: operationValue, itemIndex, offset, maxBytes }) => {
227
+ const operationId = safeOperationId(operationValue);
228
+ const owner = readOwners.get(operationId);
229
+ const index = Number(itemIndex);
230
+ const start = Number(offset);
231
+ const requested = Math.min(DESKTOP_CLIPBOARD_CHUNK_BYTES, Math.max(1, Number(maxBytes) || DESKTOP_CLIPBOARD_CHUNK_BYTES));
232
+ const item = owner?.manifest.items[index];
233
+ if (!owner || !item || item.itemIndex !== index || !Number.isSafeInteger(start) || start < 0 || start > item.size) {
234
+ throw new Error('clipboard-read-owner-invalid');
235
+ }
236
+ owner.touchedAt = now();
237
+ const byteLength = Math.min(requested, item.size - start);
238
+ const handle = await open(join(owner.stagePath, item.storageName), 'r');
239
+ try {
240
+ const buffer = Buffer.allocUnsafe(byteLength);
241
+ const { bytesRead } = await handle.read(buffer, 0, byteLength, start);
242
+ if (bytesRead !== byteLength) throw new Error('clipboard-read-incomplete');
243
+ return {
244
+ operationId,
245
+ itemIndex: index,
246
+ offset: start,
247
+ byteLength,
248
+ dataBase64: buffer.toString('base64'),
249
+ final: start + byteLength === item.size
250
+ };
251
+ } finally {
252
+ await handle.close();
253
+ }
254
+ };
255
+
256
+ const releaseRead = async operationValue => {
257
+ const operationId = safeOperationId(operationValue);
258
+ const owner = readOwners.get(operationId);
259
+ readOwners.delete(operationId);
260
+ if (owner) await removeStage(owner.stagePath);
261
+ return { ok: true };
262
+ };
263
+
264
+ const beginWrite = async manifestValue => {
265
+ await sweepExpired();
266
+ const manifest = normalizeManifest(manifestValue);
267
+ const previous = writeOwners.get(manifest.operationId);
268
+ if (previous) await removeStage(previous.stagePath);
269
+ const stagePath = await createStage('write');
270
+ await writeFile(join(stagePath, 'manifest.json'), JSON.stringify(manifest), 'utf8');
271
+ writeOwners.set(manifest.operationId, {
272
+ operationId: manifest.operationId,
273
+ stagePath,
274
+ manifest,
275
+ offsets: new Array(manifest.items.length).fill(0),
276
+ hashers: manifest.items.map(() => createHash('sha256')),
277
+ hashesVerified: new Array(manifest.items.length).fill(false),
278
+ touchedAt: now()
279
+ });
280
+ return { ok: true, operationId: manifest.operationId };
281
+ };
282
+
283
+ const writeChunk = async ({ operationId: operationValue, itemIndex, offset, dataBase64, final }) => {
284
+ const operationId = safeOperationId(operationValue);
285
+ const owner = writeOwners.get(operationId);
286
+ const index = Number(itemIndex);
287
+ const start = Number(offset);
288
+ const item = owner?.manifest.items[index];
289
+ const encoded = String(dataBase64 || '');
290
+ if (!owner || !item || item.itemIndex !== index
291
+ || !Number.isSafeInteger(start) || start !== owner.offsets[index]
292
+ || encoded.length > DESKTOP_CLIPBOARD_MAX_BASE64_CHARS
293
+ || !CANONICAL_BASE64_PATTERN.test(encoded)) {
294
+ throw new Error('clipboard-write-chunk-invalid');
295
+ }
296
+ const bytes = Buffer.from(encoded, 'base64');
297
+ if (bytes.length > DESKTOP_CLIPBOARD_CHUNK_BYTES
298
+ || start + bytes.length > item.size
299
+ || (bytes.length === 0 && item.size !== 0)
300
+ || bytes.toString('base64') !== encoded) {
301
+ throw new Error('clipboard-write-chunk-invalid');
302
+ }
303
+ const completesItem = start + bytes.length === item.size;
304
+ if ((final === true) !== completesItem) throw new Error('clipboard-write-final-mismatch');
305
+ owner.touchedAt = now();
306
+ const path = join(owner.stagePath, item.storageName);
307
+ const handle = await open(path, start === 0 ? 'w' : 'r+');
308
+ try {
309
+ const { bytesWritten } = await handle.write(bytes, 0, bytes.length, start);
310
+ if (bytesWritten !== bytes.length) throw new Error('clipboard-write-incomplete');
311
+ if (final === true) await handle.sync();
312
+ } finally {
313
+ await handle.close();
314
+ }
315
+ owner.offsets[index] += bytes.length;
316
+ owner.hashers[index].update(bytes);
317
+ const complete = owner.offsets[index] === item.size;
318
+ if (complete && !owner.hashesVerified[index]) {
319
+ const actualHash = owner.hashers[index].digest('hex');
320
+ if (actualHash !== item.sha256) throw new Error('clipboard-write-hash-mismatch');
321
+ owner.hashesVerified[index] = true;
322
+ }
323
+ return { ok: true, operationId, itemIndex: index, offset: owner.offsets[index], complete };
324
+ };
325
+
326
+ const commitWrite = async operationValue => {
327
+ const operationId = safeOperationId(operationValue);
328
+ const owner = writeOwners.get(operationId);
329
+ if (!owner
330
+ || owner.offsets.some((offset, index) => offset !== owner.manifest.items[index].size)
331
+ || owner.hashesVerified.some(verified => !verified)) {
332
+ throw new Error('clipboard-write-not-complete');
333
+ }
334
+ await runHelper(helperPath, ['--clipboard-helper', 'write', owner.stagePath, operationId], { spawnImpl });
335
+ writeOwners.delete(operationId);
336
+ if (owner.manifest.contentKind === 'files') {
337
+ retainedFileStages.push(owner);
338
+ while (retainedFileStages.length > 2) {
339
+ const stale = retainedFileStages.shift();
340
+ if (stale) await removeStage(stale.stagePath);
341
+ }
342
+ } else {
343
+ await removeStage(owner.stagePath);
344
+ }
345
+ return { ok: true, operationId, contentKind: owner.manifest.contentKind };
346
+ };
347
+
348
+ const cancelWrite = async operationValue => {
349
+ const operationId = safeOperationId(operationValue);
350
+ const owner = writeOwners.get(operationId);
351
+ writeOwners.delete(operationId);
352
+ if (owner) await removeStage(owner.stagePath);
353
+ return { ok: true };
354
+ };
355
+
356
+ const close = async () => {
357
+ for (const owner of [...readOwners.values(), ...writeOwners.values()]) {
358
+ await removeStage(owner.stagePath);
359
+ }
360
+ readOwners.clear();
361
+ writeOwners.clear();
362
+ // File clipboard entries refer to these exact local paths. Preserve the
363
+ // two bounded committed stages so Explorer/Finder can still paste after
364
+ // the LiveDesk window is hidden or the app exits.
365
+ };
366
+
367
+ return {
368
+ readSnapshot,
369
+ readChunk,
370
+ releaseRead,
371
+ beginWrite,
372
+ writeChunk,
373
+ commitWrite,
374
+ cancelWrite,
375
+ close,
376
+ getSnapshot: () => ({
377
+ readOwners: readOwners.size,
378
+ writeOwners: writeOwners.size,
379
+ retainedFileStages: retainedFileStages.length
380
+ })
381
+ };
382
+ }