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.
- package/client/package.json +5 -5
- 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/filesystem/roots.js +20 -2
- package/hub/src/remote-clipboard-contract.mjs +482 -0
- package/hub/src/remote-hub.js +1036 -214
- package/hub/src/server.js +94 -9
- 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-CaEl3dZY.js +178 -0
- package/web/dist/assets/{index-B50oR7D0.js → index-DXNmjEm7.js} +2 -2
- package/web/dist/index.html +1 -1
- package/web/dist/livedesk-build-evidence.json +18 -18
- package/web/dist/sw.js +1 -1
- package/web/dist/assets/LiveDeskApp-c1uI7vKc.js +0 -178
package/client/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@livedesk/client",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
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.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
|
-
|
|
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
|
+
}
|