livedesk 0.1.589 → 0.1.591

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.
@@ -7,7 +7,8 @@ import crypto from 'crypto';
7
7
  import { existsSync, promises as fs, statfsSync } from 'fs';
8
8
  import { spawn } from 'child_process';
9
9
  import { createRequire } from 'node:module';
10
- import { fileURLToPath } from 'node:url';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { resolveAgentShellCommand } from '../src/runtime/agent-shell.js';
11
12
 
12
13
  const require = createRequire(import.meta.url);
13
14
  const CLIENT_UPDATE_BOOTSTRAP_PATH = fileURLToPath(
@@ -1142,9 +1143,10 @@ async function resolveAgentPath(options, value, permissionMode, rejectSensitive
1142
1143
  return resolved;
1143
1144
  }
1144
1145
 
1145
- function runNodeAgentProcess(executable, args, timeoutMs = 15000, cwd = undefined) {
1146
- return new Promise((resolve, reject) => {
1147
- const child = spawn(executable, args, { cwd, windowsHide: true, shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
1146
+ function runNodeAgentProcess(executable, args, timeoutMs = 15000, cwd = undefined, maxTimeoutMs = 30000) {
1147
+ return new Promise((resolve, reject) => {
1148
+ const startedAt = Date.now();
1149
+ const child = spawn(executable, args, { cwd, windowsHide: true, shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
1148
1150
  let output = '';
1149
1151
  let timedOut = false;
1150
1152
  const append = chunk => { output = `${output}${String(chunk || '')}`.replace(/[\0\r]/g, ' ').slice(0, MAX_AGENT_OUTPUT_CHARS); };
@@ -1154,11 +1156,55 @@ function runNodeAgentProcess(executable, args, timeoutMs = 15000, cwd = undefine
1154
1156
  timedOut = true;
1155
1157
  child.kill('SIGTERM');
1156
1158
  setTimeout(() => child.kill('SIGKILL'), 1000).unref?.();
1157
- }, Math.max(1000, Math.min(30000, Number(timeoutMs) || 15000)));
1158
- child.once('error', error => { clearTimeout(timer); reject(error); });
1159
- child.once('close', code => { clearTimeout(timer); resolve({ output: redactAgentOutput(`${output}${timedOut ? '\n[timeout]' : ''}`.trim()), exitCode: timedOut ? -1 : code ?? -1, timedOut }); });
1160
- });
1161
- }
1159
+ }, Math.max(1000, Math.min(maxTimeoutMs, Number(timeoutMs) || 15000)));
1160
+ child.once('error', error => { clearTimeout(timer); reject(error); });
1161
+ child.once('close', code => { clearTimeout(timer); resolve({ output: redactAgentOutput(`${output}${timedOut ? '\n[timeout]' : ''}`.trim()), exitCode: timedOut ? -1 : code ?? -1, timedOut, durationMs: Date.now() - startedAt }); });
1162
+ });
1163
+ }
1164
+
1165
+ async function searchNodeAgentFiles(options, args, permissionMode) {
1166
+ const root = await resolveAgentPath(options, args.path, permissionMode);
1167
+ const query = String(args.query || '').trim().toLowerCase();
1168
+ if (!query || query.length > 160 || /[\0\r\n]/.test(query)) throw new Error('search query is invalid');
1169
+ const maxResults = Math.max(1, Math.min(200, Number(args.maxResults) || 100));
1170
+ const maxDepth = Math.max(0, Math.min(8, Number(args.maxDepth) || 4));
1171
+ const maxScannedEntries = Math.max(maxResults, Math.min(5000, Number(args.maxScannedEntries) || 2000));
1172
+ const queue = [{ directory: root, depth: 0 }];
1173
+ const matches = [];
1174
+ let scannedEntries = 0;
1175
+ while (queue.length > 0 && matches.length < maxResults && scannedEntries < maxScannedEntries) {
1176
+ const current = queue.shift();
1177
+ let entries;
1178
+ try {
1179
+ entries = await fs.readdir(current.directory, { withFileTypes: true });
1180
+ } catch {
1181
+ continue;
1182
+ }
1183
+ for (const entry of entries) {
1184
+ if (matches.length >= maxResults || scannedEntries >= maxScannedEntries) break;
1185
+ scannedEntries += 1;
1186
+ const entryPath = path.join(current.directory, entry.name);
1187
+ if (isSensitiveAgentPath(entryPath)) continue;
1188
+ const isDirectory = entry.isDirectory();
1189
+ if (entry.name.toLowerCase().includes(query)) {
1190
+ matches.push({ name: entry.name, path: entryPath, type: isDirectory ? 'directory' : 'file' });
1191
+ }
1192
+ if (isDirectory && current.depth < maxDepth && !entry.isSymbolicLink()) {
1193
+ queue.push({ directory: entryPath, depth: current.depth + 1 });
1194
+ }
1195
+ }
1196
+ }
1197
+ return {
1198
+ summary: `Found ${matches.length} matching path${matches.length === 1 ? '' : 's'}.`,
1199
+ data: {
1200
+ path: root,
1201
+ query,
1202
+ matches,
1203
+ scannedEntries,
1204
+ truncated: matches.length >= maxResults || scannedEntries >= maxScannedEntries
1205
+ }
1206
+ };
1207
+ }
1162
1208
 
1163
1209
  function redactAgentOutput(value) {
1164
1210
  let output = String(value || '').replace(/[\0\r]/g, ' ');
@@ -1324,7 +1370,7 @@ async function executeNodeAgentOperation(options, operation, payload = {}) {
1324
1370
  else await fs.unlink(filePath);
1325
1371
  return { summary: `Deleted ${path.basename(filePath)}.`, data: { path: filePath, recursive: args.recursive === true } };
1326
1372
  }
1327
- if (operation === 'file.list') {
1373
+ if (operation === 'file.list') {
1328
1374
  const directory = await resolveAgentPath(options, args.path, permissionMode);
1329
1375
  const entries = await fs.readdir(directory, { withFileTypes: true });
1330
1376
  const maxEntries = Math.max(1, Math.min(500, Number(args.maxEntries) || 200));
@@ -1334,8 +1380,15 @@ async function executeNodeAgentOperation(options, operation, payload = {}) {
1334
1380
  if (isSensitiveAgentPath(entryPath)) continue;
1335
1381
  data.push({ name: entry.name, path: entryPath, type: entry.isDirectory() ? 'directory' : 'file', sizeBytes: entry.isDirectory() ? 0 : (await fs.stat(entryPath)).size });
1336
1382
  }
1337
- return { summary: `Listed ${data.length} entries.`, data: { path: directory, entries: data, truncated: entries.length > maxEntries } };
1338
- }
1383
+ return { summary: `Listed ${data.length} entries.`, data: { path: directory, entries: data, truncated: entries.length > maxEntries } };
1384
+ }
1385
+ if (operation === 'file.search') return searchNodeAgentFiles(options, args, permissionMode);
1386
+ if (operation === 'directory.create') {
1387
+ let directory = await resolveAgentPath(options, args.path, permissionMode);
1388
+ await fs.mkdir(directory, { recursive: true });
1389
+ directory = await resolveAgentPath(options, args.path, permissionMode);
1390
+ return { summary: `Created directory ${path.basename(directory)}.`, data: { path: directory, created: true } };
1391
+ }
1339
1392
  if (operation === 'application.launch') {
1340
1393
  const executable = String(args.executable || '').trim();
1341
1394
  if (!executable || executable.length > 400 || /[\0\r\n]/.test(executable)) throw new Error('executable is invalid');
@@ -1389,12 +1442,13 @@ async function executeNodeAgentOperation(options, operation, payload = {}) {
1389
1442
  if (!ok) throw new Error(`Service ${action} failed for ${service}.`);
1390
1443
  return { summary: `Service ${action} completed for ${service}.`, data: { service, action, results } };
1391
1444
  }
1392
- if (operation === 'command.run') {
1393
- const command = String(args.command || '');
1394
- const executable = process.platform === 'win32' ? 'cmd.exe' : '/bin/sh';
1395
- const commandArgs = process.platform === 'win32' ? ['/d', '/s', '/c', command] : ['-lc', command];
1396
- const result = await runNodeAgentProcess(executable, commandArgs, args.timeoutMs, args.workingDirectory ? await resolveAgentPath(options, args.workingDirectory, permissionMode, false) : undefined);
1397
- return { summary: result.timedOut ? 'Command timed out.' : `Command exited with code ${result.exitCode}.`, data: { ...result, output: redactAgentOutput(result.output) } };
1445
+ if (operation === 'command.run') {
1446
+ const command = String(args.command || '');
1447
+ if (!command || command.length > 16000 || /\0/.test(command)) throw new Error('command is invalid');
1448
+ const shellCommand = resolveAgentShellCommand({ shell: args.shell, command });
1449
+ const workingDirectory = args.workingDirectory ? await resolveAgentPath(options, args.workingDirectory, permissionMode, false) : undefined;
1450
+ const result = await runNodeAgentProcess(shellCommand.executable, shellCommand.args, args.timeoutMs, workingDirectory, 300000);
1451
+ return { summary: result.timedOut ? `${shellCommand.shell} command timed out.` : `${shellCommand.shell} command exited with code ${result.exitCode}.`, data: { ...result, output: redactAgentOutput(result.output), shell: shellCommand.shell, workingDirectory: workingDirectory || process.cwd() } };
1398
1452
  }
1399
1453
  if (operation === 'script.run') {
1400
1454
  const scriptPath = await resolveAgentPath(options, args.path, permissionMode);
@@ -1481,7 +1535,7 @@ function normalizeNodeAgentTaskResult(result) {
1481
1535
  return { ...result, ok, status: ok ? 'completed' : 'failed', error };
1482
1536
  }
1483
1537
 
1484
- const NODE_AGENT_OPERATIONS = new Set(['process.control', 'service.control', 'application.launch', 'application.close', 'file.read', 'file.write', 'file.delete', 'file.list', 'command.run', 'script.run', 'software.install', 'network.status', 'system.power', 'system.configure', 'logs.collect']);
1538
+ const NODE_AGENT_OPERATIONS = new Set(['process.control', 'service.control', 'application.launch', 'application.close', 'file.read', 'file.write', 'file.delete', 'file.list', 'file.search', 'directory.create', 'command.run', 'script.run', 'software.install', 'network.status', 'system.power', 'system.configure', 'logs.collect']);
1485
1539
 
1486
1540
  function remotePolicyAllows(options, command) {
1487
1541
  const policy = options.effectivePolicy;
@@ -1723,7 +1777,7 @@ async function handleRemoteCommand(socket, options, message, nextFrameSeq, activ
1723
1777
  summary: result.summary,
1724
1778
  error: result.error || undefined,
1725
1779
  data: result.data,
1726
- sideEffects: ['file.read', 'file.list', 'network.status', 'logs.collect'].includes(command) ? 'none' : 'audited',
1780
+ sideEffects: ['file.read', 'file.list', 'file.search', 'network.status', 'logs.collect'].includes(command) ? 'none' : 'audited',
1727
1781
  completedAt: new Date().toISOString()
1728
1782
  }
1729
1783
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/client",
3
- "version": "0.1.249",
3
+ "version": "0.1.251",
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.447",
46
+ "@livedesk/fast-osx-arm64": "0.1.447",
47
+ "@livedesk/fast-osx-x64": "0.1.447",
48
+ "@livedesk/fast-win-x64": "0.1.447"
49
49
  },
50
50
  "publishConfig": {
51
51
  "access": "public"
@@ -0,0 +1,66 @@
1
+ import { existsSync } from 'node:fs';
2
+
3
+ export const AGENT_SHELL_NAMES = Object.freeze([
4
+ 'auto',
5
+ 'powershell',
6
+ 'pwsh',
7
+ 'cmd',
8
+ 'sh',
9
+ 'bash',
10
+ 'zsh'
11
+ ]);
12
+
13
+ function unixShellPath(shell, platform, fileExists) {
14
+ if (shell === 'sh') return '/bin/sh';
15
+ if (shell === 'bash') return '/bin/bash';
16
+ if (shell === 'zsh') return '/bin/zsh';
17
+ if (shell === 'pwsh') return 'pwsh';
18
+ if (shell === 'powershell' || shell === 'cmd') {
19
+ throw new Error(`${shell} is not available on ${platform}.`);
20
+ }
21
+ if (platform === 'darwin' && fileExists('/bin/zsh')) return '/bin/zsh';
22
+ if (fileExists('/bin/bash')) return '/bin/bash';
23
+ return '/bin/sh';
24
+ }
25
+
26
+ export function resolveAgentShellCommand({
27
+ platform = process.platform,
28
+ shell = 'auto',
29
+ command = '',
30
+ fileExists = existsSync
31
+ } = {}) {
32
+ const normalizedShell = String(shell || 'auto').trim().toLowerCase();
33
+ if (!AGENT_SHELL_NAMES.includes(normalizedShell)) {
34
+ throw new Error(`Unsupported shell: ${normalizedShell || 'empty'}.`);
35
+ }
36
+ if (platform === 'win32') {
37
+ if (['sh', 'bash', 'zsh'].includes(normalizedShell)) {
38
+ throw new Error(`${normalizedShell} is not available on Windows.`);
39
+ }
40
+ if (normalizedShell === 'cmd') {
41
+ return {
42
+ shell: 'cmd',
43
+ executable: process.env.ComSpec || 'cmd.exe',
44
+ args: ['/d', '/s', '/c', String(command)]
45
+ };
46
+ }
47
+ const powershellCore = normalizedShell === 'pwsh';
48
+ return {
49
+ shell: powershellCore ? 'pwsh' : 'powershell',
50
+ executable: powershellCore ? 'pwsh.exe' : 'powershell.exe',
51
+ args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', String(command)]
52
+ };
53
+ }
54
+
55
+ const executable = unixShellPath(normalizedShell, platform, fileExists);
56
+ if (executable.startsWith('/') && !fileExists(executable)) {
57
+ throw new Error(`${normalizedShell} is not installed at ${executable}.`);
58
+ }
59
+ return {
60
+ shell: normalizedShell === 'auto'
61
+ ? executable.endsWith('/zsh') ? 'zsh' : executable.endsWith('/bash') ? 'bash' : 'sh'
62
+ : normalizedShell,
63
+ executable,
64
+ args: ['-lc', String(command)]
65
+ };
66
+ }
@@ -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
+ }