@penpair/core 0.4.0 → 0.5.0
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/dist/adapters.d.ts +1 -1
- package/dist/adapters.js +1 -0
- package/dist/browser-wake.d.ts +22 -0
- package/dist/browser-wake.js +241 -0
- package/dist/cli-main.js +140 -15
- package/dist/clipboard-payload.d.ts +2 -0
- package/dist/clipboard-payload.js +89 -0
- package/dist/design-commands/context.d.ts +5 -0
- package/dist/design-commands/copy-command.d.ts +3 -0
- package/dist/design-commands/copy-command.js +101 -0
- package/dist/design-commands/release-command.js +6 -1
- package/dist/design-releases/copy-evidence.d.ts +33 -0
- package/dist/design-releases/copy-evidence.js +278 -0
- package/dist/design-releases/copy-plan.d.ts +49 -0
- package/dist/design-releases/copy-plan.js +55 -0
- package/dist/design-releases/copy-snippets.d.ts +11 -0
- package/dist/design-releases/copy-snippets.js +293 -0
- package/dist/design-releases/copy.d.ts +28 -0
- package/dist/design-releases/copy.js +466 -0
- package/dist/design-releases/index.d.ts +1 -0
- package/dist/design-releases/index.js +1 -0
- package/dist/errors.js +1 -1
- package/dist/instance/connection.d.ts +2 -0
- package/dist/instance/connection.js +15 -3
- package/dist/kit-source.js +1 -1
- package/dist/layout-policy.js +21 -11
- package/dist/layout-settling.d.ts +1 -0
- package/dist/layout-settling.js +51 -17
- package/dist/layout.d.ts +7 -1
- package/dist/layout.js +19 -3
- package/dist/mcp-server.js +99 -3
- package/dist/native-editor-clipboard.d.ts +17 -0
- package/dist/native-editor-clipboard.js +470 -0
- package/dist/plugin-health.d.ts +13 -0
- package/dist/plugin-health.js +39 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { open, readFile, unlink } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { inspectBrowserTarget } from "./browser-wake.js";
|
|
6
|
+
import { nativeClipboardPayloadMatches } from "./clipboard-payload.js";
|
|
7
|
+
const maximumBytes = 1024 * 1024;
|
|
8
|
+
const focusExpression = `(() => {
|
|
9
|
+
const element = document.activeElement;
|
|
10
|
+
const canvas = document.querySelector("#render, svg.viewport-controls, canvas[data-testid='workspace-canvas']");
|
|
11
|
+
const blocked = !element || element.matches("input,textarea,select,iframe,[contenteditable='true']") || element.isContentEditable || element.closest("[role='dialog'],dialog");
|
|
12
|
+
const workspaceFocus = element === document.body || element === document.documentElement || !!element.closest("#render,svg.viewport-controls,[data-testid='workspace-canvas']");
|
|
13
|
+
return {href:location.href,platform:navigator.platform,focused:document.hasFocus(),canvas:!!canvas,safe:!blocked && workspaceFocus};
|
|
14
|
+
})()`;
|
|
15
|
+
async function interrupted(signal, work) {
|
|
16
|
+
signal.throwIfAborted();
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const abort = () => reject(new Error("Native clipboard metadata deadline exceeded or cancelled"));
|
|
19
|
+
signal.addEventListener("abort", abort, {
|
|
20
|
+
once: true,
|
|
21
|
+
});
|
|
22
|
+
void work()
|
|
23
|
+
.then(resolve, reject)
|
|
24
|
+
.finally(() => signal.removeEventListener("abort", abort));
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
function targetMatches(href, options, expectedPageId) {
|
|
28
|
+
if (typeof href !== "string") {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const url = new URL(href);
|
|
33
|
+
const expected = new URL(options.origin);
|
|
34
|
+
const local = (value) => value.protocol === "http:" &&
|
|
35
|
+
[
|
|
36
|
+
"localhost",
|
|
37
|
+
"127.0.0.1",
|
|
38
|
+
].includes(value.hostname);
|
|
39
|
+
const sameOrigin = url.origin === expected.origin ||
|
|
40
|
+
(options.allowLoopbackAlias === true &&
|
|
41
|
+
local(url) &&
|
|
42
|
+
local(expected) &&
|
|
43
|
+
url.port === expected.port);
|
|
44
|
+
const route = url.hash.slice(1);
|
|
45
|
+
const query = route.indexOf("?");
|
|
46
|
+
const parameters = query >= 0 && route.slice(0, query) === "/workspace"
|
|
47
|
+
? new URLSearchParams(route.slice(query + 1))
|
|
48
|
+
: url.pathname === "/workspace" && !url.hash
|
|
49
|
+
? url.searchParams
|
|
50
|
+
: undefined;
|
|
51
|
+
return (sameOrigin &&
|
|
52
|
+
!url.username &&
|
|
53
|
+
!url.password &&
|
|
54
|
+
parameters?.getAll("file-id").length === 1 &&
|
|
55
|
+
parameters.get("file-id") === options.fileId &&
|
|
56
|
+
(expectedPageId === undefined ||
|
|
57
|
+
(parameters.getAll("page-id").length === 1 && parameters.get("page-id") === expectedPageId)));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function debuggingSocketUrl(options, targetId, signal) {
|
|
64
|
+
let response;
|
|
65
|
+
try {
|
|
66
|
+
response = await (options.fetch ?? fetch)(`http://127.0.0.1:${options.port}/json/list`, {
|
|
67
|
+
redirect: "error",
|
|
68
|
+
signal,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
throw new Error("Cannot inspect the local clipboard debugging target");
|
|
73
|
+
}
|
|
74
|
+
if (!response.ok || response.redirected || !response.body) {
|
|
75
|
+
void response.body?.cancel().catch(() => undefined);
|
|
76
|
+
throw new Error("The local clipboard debugging endpoint refused inspection");
|
|
77
|
+
}
|
|
78
|
+
const reader = response.body.getReader();
|
|
79
|
+
const chunks = [];
|
|
80
|
+
let bytes = 0;
|
|
81
|
+
try {
|
|
82
|
+
while (true) {
|
|
83
|
+
const next = await reader.read();
|
|
84
|
+
if (next.done) {
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
bytes += next.value.byteLength;
|
|
88
|
+
if (bytes > maximumBytes) {
|
|
89
|
+
throw new Error("Clipboard target metadata exceeds its byte limit");
|
|
90
|
+
}
|
|
91
|
+
chunks.push(next.value);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
throw new Error("Cannot read bounded clipboard target metadata");
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
void reader.cancel().catch(() => undefined);
|
|
99
|
+
reader.releaseLock();
|
|
100
|
+
}
|
|
101
|
+
let values;
|
|
102
|
+
try {
|
|
103
|
+
values = JSON.parse(Buffer.concat(chunks, bytes).toString("utf8"));
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
throw new Error("Invalid clipboard target metadata");
|
|
107
|
+
}
|
|
108
|
+
if (!Array.isArray(values) || values.length > 1000) {
|
|
109
|
+
throw new Error("Invalid clipboard target metadata");
|
|
110
|
+
}
|
|
111
|
+
const matches = values.filter((value) => value && typeof value === "object" && value.id === targetId);
|
|
112
|
+
const target = matches.length === 1 ? matches[0] : undefined;
|
|
113
|
+
if (!target ||
|
|
114
|
+
target.type !== "page" ||
|
|
115
|
+
!targetMatches(target.url, options) ||
|
|
116
|
+
typeof target.webSocketDebuggerUrl !== "string") {
|
|
117
|
+
throw new Error("Clipboard debugging target changed or became ambiguous");
|
|
118
|
+
}
|
|
119
|
+
let url;
|
|
120
|
+
try {
|
|
121
|
+
url = new URL(target.webSocketDebuggerUrl);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
throw new Error("Invalid clipboard debugging socket");
|
|
125
|
+
}
|
|
126
|
+
if (url.protocol !== "ws:" ||
|
|
127
|
+
![
|
|
128
|
+
"127.0.0.1",
|
|
129
|
+
"localhost",
|
|
130
|
+
].includes(url.hostname) ||
|
|
131
|
+
Number(url.port || 80) !== options.port ||
|
|
132
|
+
url.username ||
|
|
133
|
+
url.password ||
|
|
134
|
+
url.search ||
|
|
135
|
+
url.hash ||
|
|
136
|
+
url.pathname !== `/devtools/page/${targetId}`) {
|
|
137
|
+
throw new Error("Clipboard debugging socket must stay on the selected loopback target");
|
|
138
|
+
}
|
|
139
|
+
return url.href;
|
|
140
|
+
}
|
|
141
|
+
async function clipboardLease() {
|
|
142
|
+
const destination = path.join(os.tmpdir(), `penpair-native-clipboard-${os.userInfo().uid}.lock`);
|
|
143
|
+
const token = randomUUID();
|
|
144
|
+
let handle;
|
|
145
|
+
try {
|
|
146
|
+
handle = await open(destination, "wx", 0o600);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
throw new Error("Another native clipboard session holds the local clipboard lock; inspect it before retrying");
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
await handle.writeFile(JSON.stringify({
|
|
153
|
+
token,
|
|
154
|
+
pid: process.pid,
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
await unlink(destination).catch(() => undefined);
|
|
159
|
+
throw new Error("Cannot persist native clipboard lock ownership");
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
await handle.close();
|
|
163
|
+
}
|
|
164
|
+
return async () => {
|
|
165
|
+
try {
|
|
166
|
+
const current = JSON.parse(await readFile(destination, "utf8"));
|
|
167
|
+
if (current.token !== token) {
|
|
168
|
+
throw new Error("Native clipboard lock ownership changed");
|
|
169
|
+
}
|
|
170
|
+
await unlink(destination);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
throw new Error("Cannot safely release native clipboard lock; inspect its ownership");
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/** Holds a short exclusive clipboard session; closing releases control, never the browser. */
|
|
178
|
+
export async function connectNativeClipboard(options) {
|
|
179
|
+
const controller = new AbortController();
|
|
180
|
+
const signal = options.signal
|
|
181
|
+
? AbortSignal.any([
|
|
182
|
+
options.signal,
|
|
183
|
+
controller.signal,
|
|
184
|
+
])
|
|
185
|
+
: controller.signal;
|
|
186
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 5000);
|
|
187
|
+
timer.unref();
|
|
188
|
+
let release;
|
|
189
|
+
let socket;
|
|
190
|
+
let closed = false;
|
|
191
|
+
let active = false;
|
|
192
|
+
let copied = false;
|
|
193
|
+
let copiedDigest;
|
|
194
|
+
let copiedFrames = [];
|
|
195
|
+
let pasteAttempted = false;
|
|
196
|
+
let nextId = 0;
|
|
197
|
+
const pending = new Map();
|
|
198
|
+
const fail = () => {
|
|
199
|
+
for (const request of pending.values()) {
|
|
200
|
+
request.reject(new Error("Native clipboard connection ended or exceeded its deadline"));
|
|
201
|
+
}
|
|
202
|
+
pending.clear();
|
|
203
|
+
};
|
|
204
|
+
const message = (event) => {
|
|
205
|
+
const data = event.data;
|
|
206
|
+
if (typeof data !== "string" || Buffer.byteLength(data) > maximumBytes) {
|
|
207
|
+
fail();
|
|
208
|
+
socket?.close();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
let value;
|
|
212
|
+
try {
|
|
213
|
+
value = JSON.parse(data);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
fail();
|
|
217
|
+
socket?.close();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (!value || typeof value !== "object") {
|
|
221
|
+
fail();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (typeof value.id !== "number") {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const request = pending.get(value.id);
|
|
228
|
+
if (!request) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
pending.delete(value.id);
|
|
232
|
+
if (value.error || !value.result || typeof value.result !== "object") {
|
|
233
|
+
request.reject(new Error("The browser refused a native clipboard operation"));
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
request.resolve(value.result);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
const aborted = () => {
|
|
240
|
+
fail();
|
|
241
|
+
socket?.close();
|
|
242
|
+
};
|
|
243
|
+
signal.addEventListener("abort", aborted, {
|
|
244
|
+
once: true,
|
|
245
|
+
});
|
|
246
|
+
const close = async () => {
|
|
247
|
+
if (closed) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
closed = true;
|
|
251
|
+
clearTimeout(timer);
|
|
252
|
+
signal.removeEventListener("abort", aborted);
|
|
253
|
+
fail();
|
|
254
|
+
socket?.removeEventListener("message", message);
|
|
255
|
+
socket?.removeEventListener("close", fail);
|
|
256
|
+
socket?.removeEventListener("error", fail);
|
|
257
|
+
socket?.close();
|
|
258
|
+
await release?.();
|
|
259
|
+
};
|
|
260
|
+
const send = (method, params = {}) => {
|
|
261
|
+
if (closed || signal.aborted || !socket || socket.readyState !== 1 || pending.size >= 1) {
|
|
262
|
+
return Promise.reject(new Error("Native clipboard connection is unavailable or busy"));
|
|
263
|
+
}
|
|
264
|
+
return new Promise((resolve, reject) => {
|
|
265
|
+
const id = ++nextId;
|
|
266
|
+
pending.set(id, {
|
|
267
|
+
resolve,
|
|
268
|
+
reject,
|
|
269
|
+
});
|
|
270
|
+
try {
|
|
271
|
+
socket.send(JSON.stringify({
|
|
272
|
+
id,
|
|
273
|
+
method,
|
|
274
|
+
params,
|
|
275
|
+
}));
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
pending.delete(id);
|
|
279
|
+
reject(new Error("Native clipboard command could not be dispatched"));
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
};
|
|
283
|
+
try {
|
|
284
|
+
const target = await inspectBrowserTarget({
|
|
285
|
+
...options,
|
|
286
|
+
signal,
|
|
287
|
+
});
|
|
288
|
+
const url = await interrupted(signal, () => debuggingSocketUrl(options, target.targetId, signal));
|
|
289
|
+
signal.throwIfAborted();
|
|
290
|
+
release = await clipboardLease();
|
|
291
|
+
socket = (options.createSocket ?? ((address) => new WebSocket(address)))(url);
|
|
292
|
+
socket.addEventListener("message", message);
|
|
293
|
+
socket.addEventListener("close", fail);
|
|
294
|
+
socket.addEventListener("error", fail);
|
|
295
|
+
await new Promise((resolve, reject) => {
|
|
296
|
+
const opened = () => {
|
|
297
|
+
cleanup();
|
|
298
|
+
resolve();
|
|
299
|
+
};
|
|
300
|
+
const failed = () => {
|
|
301
|
+
cleanup();
|
|
302
|
+
reject(new Error("Native clipboard debugging connection failed"));
|
|
303
|
+
};
|
|
304
|
+
const cleanup = () => {
|
|
305
|
+
socket.removeEventListener("open", opened);
|
|
306
|
+
socket.removeEventListener("close", failed);
|
|
307
|
+
socket.removeEventListener("error", failed);
|
|
308
|
+
signal.removeEventListener("abort", failed);
|
|
309
|
+
};
|
|
310
|
+
socket.addEventListener("open", opened, {
|
|
311
|
+
once: true,
|
|
312
|
+
});
|
|
313
|
+
socket.addEventListener("close", failed, {
|
|
314
|
+
once: true,
|
|
315
|
+
});
|
|
316
|
+
socket.addEventListener("error", failed, {
|
|
317
|
+
once: true,
|
|
318
|
+
});
|
|
319
|
+
signal.addEventListener("abort", failed, {
|
|
320
|
+
once: true,
|
|
321
|
+
});
|
|
322
|
+
if (signal.aborted) {
|
|
323
|
+
failed();
|
|
324
|
+
}
|
|
325
|
+
else if (socket.readyState === 1) {
|
|
326
|
+
opened();
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
const evaluate = async (expression) => {
|
|
330
|
+
const response = await send("Runtime.evaluate", {
|
|
331
|
+
expression,
|
|
332
|
+
returnByValue: true,
|
|
333
|
+
awaitPromise: true,
|
|
334
|
+
userGesture: true,
|
|
335
|
+
});
|
|
336
|
+
const result = response.result;
|
|
337
|
+
return result?.value;
|
|
338
|
+
};
|
|
339
|
+
const inspectClipboard = async () => evaluate(`(async function inspectNativeClipboard() {
|
|
340
|
+
try {
|
|
341
|
+
const text = await navigator.clipboard.readText();
|
|
342
|
+
const valid = (${nativeClipboardPayloadMatches.toString()})(text, ${JSON.stringify(options.fileId)}, ${JSON.stringify(copiedFrames)});
|
|
343
|
+
if (!valid) return {validated:false};
|
|
344
|
+
const bytes = new TextEncoder().encode(text);
|
|
345
|
+
const hash = await crypto.subtle.digest("SHA-256", bytes);
|
|
346
|
+
const digest = Array.from(new Uint8Array(hash), value => value.toString(16).padStart(2,"0")).join("");
|
|
347
|
+
return {validated:true,digest};
|
|
348
|
+
} catch { return {validated:false,unavailable:true}; }
|
|
349
|
+
})()`);
|
|
350
|
+
const shortcut = async (kind, expectedPageId, expectedFrameIds) => {
|
|
351
|
+
if (active || (kind === "paste" && (!copied || pasteAttempted))) {
|
|
352
|
+
throw new Error("Native paste requires one completed copy and cannot be replayed");
|
|
353
|
+
}
|
|
354
|
+
if (typeof expectedPageId !== "string" || !expectedPageId.trim()) {
|
|
355
|
+
throw new Error("Native clipboard requires an explicit page ID");
|
|
356
|
+
}
|
|
357
|
+
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
|
|
358
|
+
if (kind === "copy" &&
|
|
359
|
+
(!Array.isArray(expectedFrameIds) ||
|
|
360
|
+
!expectedFrameIds.length ||
|
|
361
|
+
expectedFrameIds.length > 100 ||
|
|
362
|
+
!uuid.test(options.fileId) ||
|
|
363
|
+
expectedFrameIds.some((id) => typeof id !== "string" || !uuid.test(id)) ||
|
|
364
|
+
new Set(expectedFrameIds.map((id) => id.toLowerCase())).size !== expectedFrameIds.length)) {
|
|
365
|
+
throw new Error("Native copy requires distinct explicit frame UUIDs and a pinned file UUID");
|
|
366
|
+
}
|
|
367
|
+
active = true;
|
|
368
|
+
try {
|
|
369
|
+
await send("Page.bringToFront");
|
|
370
|
+
const state = await evaluate(focusExpression);
|
|
371
|
+
if (!state ||
|
|
372
|
+
!targetMatches(state.href, options, expectedPageId) ||
|
|
373
|
+
state.focused !== true ||
|
|
374
|
+
state.canvas !== true ||
|
|
375
|
+
state.safe !== true ||
|
|
376
|
+
typeof state.platform !== "string") {
|
|
377
|
+
throw new Error("Native clipboard requires the pinned editor canvas focused, without an editable field, plugin or dialog");
|
|
378
|
+
}
|
|
379
|
+
const mac = /mac/i.test(state.platform);
|
|
380
|
+
const key = kind === "copy" ? "c" : "v";
|
|
381
|
+
if (kind === "copy") {
|
|
382
|
+
copied = false;
|
|
383
|
+
copiedDigest = undefined;
|
|
384
|
+
copiedFrames = [
|
|
385
|
+
...expectedFrameIds,
|
|
386
|
+
];
|
|
387
|
+
const sentinel = `penpair-native-copy-pending-${randomUUID()}`;
|
|
388
|
+
const cleared = await evaluate(`(async function clearNativeClipboard(){try{await navigator.clipboard.writeText(${JSON.stringify(sentinel)});return {cleared:true};}catch{return {cleared:false};}})()`);
|
|
389
|
+
if (cleared?.cleared !== true) {
|
|
390
|
+
throw new Error("Browser clipboard write permission is required; no native copy was dispatched");
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
const current = await inspectClipboard();
|
|
395
|
+
if (current?.validated !== true || current.digest !== copiedDigest) {
|
|
396
|
+
throw new Error("Native clipboard changed or cannot be verified; no paste was dispatched");
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (kind === "paste") {
|
|
400
|
+
pasteAttempted = true;
|
|
401
|
+
}
|
|
402
|
+
let dispatched = false;
|
|
403
|
+
try {
|
|
404
|
+
await send("Input.dispatchKeyEvent", {
|
|
405
|
+
type: "rawKeyDown",
|
|
406
|
+
commands: mac
|
|
407
|
+
? [
|
|
408
|
+
kind,
|
|
409
|
+
]
|
|
410
|
+
: [],
|
|
411
|
+
modifiers: mac ? 4 : 2,
|
|
412
|
+
key,
|
|
413
|
+
code: `Key${key.toUpperCase()}`,
|
|
414
|
+
windowsVirtualKeyCode: key.toUpperCase().charCodeAt(0),
|
|
415
|
+
});
|
|
416
|
+
dispatched = true;
|
|
417
|
+
}
|
|
418
|
+
finally {
|
|
419
|
+
// Release the physical shortcut even if its acknowledgment was lost.
|
|
420
|
+
const released = send("Input.dispatchKeyEvent", {
|
|
421
|
+
type: "keyUp",
|
|
422
|
+
modifiers: mac ? 4 : 2,
|
|
423
|
+
key,
|
|
424
|
+
code: `Key${key.toUpperCase()}`,
|
|
425
|
+
windowsVirtualKeyCode: key.toUpperCase().charCodeAt(0),
|
|
426
|
+
});
|
|
427
|
+
if (dispatched) {
|
|
428
|
+
await released;
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
await released.catch(() => undefined);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (kind === "copy") {
|
|
435
|
+
while (!copied) {
|
|
436
|
+
signal.throwIfAborted();
|
|
437
|
+
const observed = await inspectClipboard();
|
|
438
|
+
if (observed?.unavailable === true) {
|
|
439
|
+
throw new Error("Browser clipboard read permission is required; native paste remains disabled");
|
|
440
|
+
}
|
|
441
|
+
if (observed?.validated === true &&
|
|
442
|
+
typeof observed.digest === "string" &&
|
|
443
|
+
/^[a-f0-9]{64}$/.test(observed.digest)) {
|
|
444
|
+
copiedDigest = observed.digest;
|
|
445
|
+
copied = true;
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
finally {
|
|
454
|
+
active = false;
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
return {
|
|
458
|
+
copy: (expectedPageId, expectedFrameIds) => shortcut("copy", expectedPageId, expectedFrameIds),
|
|
459
|
+
paste: (expectedPageId) => shortcut("paste", expectedPageId),
|
|
460
|
+
close,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
await close();
|
|
465
|
+
if (signal.aborted) {
|
|
466
|
+
throw new Error("Native clipboard connection cancelled or exceeded its deadline");
|
|
467
|
+
}
|
|
468
|
+
throw error;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface PluginHealth {
|
|
2
|
+
state: "responsive" | "suspended" | "unknown";
|
|
3
|
+
heartbeatAgeMs: number | null;
|
|
4
|
+
heartbeatSource: "upstream-error" | "unavailable";
|
|
5
|
+
keepAlive: {
|
|
6
|
+
method: "unknown";
|
|
7
|
+
verified: false;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
/** Fresh execution proves responsiveness, not the plugin's heartbeat time or browser policy. */
|
|
11
|
+
export declare function responsivePluginHealth(): PluginHealth;
|
|
12
|
+
/** Upstream error text is the only heartbeat telemetry exposed by the current connection. */
|
|
13
|
+
export declare function pluginHealthFromError(error: unknown): PluginHealth;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
function health(state, heartbeatAgeMs) {
|
|
2
|
+
return {
|
|
3
|
+
state,
|
|
4
|
+
heartbeatAgeMs,
|
|
5
|
+
heartbeatSource: heartbeatAgeMs === null ? "unavailable" : "upstream-error",
|
|
6
|
+
keepAlive: {
|
|
7
|
+
method: "unknown",
|
|
8
|
+
verified: false,
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/** Fresh execution proves responsiveness, not the plugin's heartbeat time or browser policy. */
|
|
13
|
+
export function responsivePluginHealth() {
|
|
14
|
+
return health("responsive", null);
|
|
15
|
+
}
|
|
16
|
+
/** Upstream error text is the only heartbeat telemetry exposed by the current connection. */
|
|
17
|
+
export function pluginHealthFromError(error) {
|
|
18
|
+
const message = error instanceof Error
|
|
19
|
+
? error.message.slice(0, 8192)
|
|
20
|
+
: typeof error === "string"
|
|
21
|
+
? error.slice(0, 8192)
|
|
22
|
+
: "";
|
|
23
|
+
const code = typeof error === "object" && error !== null && "code" in error ? error.code : undefined;
|
|
24
|
+
const suspended = code === "plugin-suspended" || /\bsuspended\b|\bno heartbeat\b/i.test(message);
|
|
25
|
+
if (!suspended) {
|
|
26
|
+
return health("unknown", null);
|
|
27
|
+
}
|
|
28
|
+
const duration = /\bno heartbeat\s+for\s+([^\s,)]+?)\s*(milliseconds?|ms|seconds?|s)\b/i.exec(message);
|
|
29
|
+
if (!duration || !/^\d+(?:\.\d+)?$/.test(duration[1])) {
|
|
30
|
+
return health("suspended", null);
|
|
31
|
+
}
|
|
32
|
+
const value = Number(duration[1]);
|
|
33
|
+
const heartbeatAgeMs = value * (/^(?:ms|milliseconds?)$/i.test(duration[2]) ? 1 : 1000);
|
|
34
|
+
return health("suspended", Number.isFinite(heartbeatAgeMs) &&
|
|
35
|
+
heartbeatAgeMs >= 0 &&
|
|
36
|
+
heartbeatAgeMs <= Number.MAX_SAFE_INTEGER
|
|
37
|
+
? heartbeatAgeMs
|
|
38
|
+
: null);
|
|
39
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Kept in its own file so that the command line and the library entry can both read it without importing each other. Equals package.json (a test checks). */
|
|
2
|
-
export declare const version = "0.
|
|
2
|
+
export declare const version = "0.5.0";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Kept in its own file so that the command line and the library entry can both read it without importing each other. Equals package.json (a test checks). */
|
|
2
|
-
export const version = "0.
|
|
2
|
+
export const version = "0.5.0";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@penpair/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Design together. Build together. The library and engine behind penpair: connect AI agents and people to a Penpot design file, safely. Install @penpair/web or @penpair/docker for the command.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|