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.
- package/client/bin/livedesk-client-node.js +74 -20
- package/client/package.json +5 -5
- package/client/src/runtime/agent-shell.js +66 -0
- package/electron/auth-session-owner.mjs +24 -5
- package/electron/desktop-clipboard-owner.mjs +382 -0
- package/electron/main.mjs +166 -50
- package/electron/preload.cjs +13 -4
- package/electron/runtime-role-transition.mjs +17 -0
- package/hub/package.json +2 -2
- package/hub/src/agents/agent-tool-registry.js +34 -8
- package/hub/src/filesystem/roots.js +20 -2
- package/hub/src/remote-clipboard-contract.mjs +482 -0
- package/hub/src/remote-hub.js +1041 -217
- package/hub/src/server.js +99 -12
- package/hub/src/settings/effective-device-policy.js +31 -9
- package/package.json +6 -6
- package/web/dist/app.webmanifest +1 -1
- package/web/dist/assets/LiveDeskApp-BnYbdi9v.js +178 -0
- package/web/dist/assets/{icons-DwJmccVC.js → icons-CebEqIp7.js} +1 -1
- package/web/dist/assets/{index-BdlXRb_r.js → index-BPgqiKB-.js} +4 -4
- package/web/dist/assets/{react-DOZUPHF8.js → react-BHQWCi9c.js} +1 -1
- package/web/dist/assets/supabase-C0Z0FS3K.js +29 -0
- package/web/dist/index.html +3 -3
- package/web/dist/livedesk-build-evidence.json +22 -22
- package/web/dist/sw.js +1 -1
- package/web/dist/assets/LiveDeskApp-ANZPNHuO.js +0 -178
- package/web/dist/assets/supabase-C7qjtLN3.js +0 -29
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
1395
|
-
const
|
|
1396
|
-
const
|
|
1397
|
-
|
|
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
|
});
|
package/client/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@livedesk/client",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
46
|
-
"@livedesk/fast-osx-arm64": "0.1.
|
|
47
|
-
"@livedesk/fast-osx-x64": "0.1.
|
|
48
|
-
"@livedesk/fast-win-x64": "0.1.
|
|
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
|
-
|
|
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
|
+
}
|