electron-snapora 1.0.1
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/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.es.md +79 -0
- package/README.ja.md +79 -0
- package/README.ko.md +79 -0
- package/README.md +392 -0
- package/README.zh-CN.md +79 -0
- package/dist/core/index.cjs +184 -0
- package/dist/core/index.cjs.map +1 -0
- package/dist/core/index.d.cts +97 -0
- package/dist/core/index.d.ts +97 -0
- package/dist/core/index.mjs +150 -0
- package/dist/core/index.mjs.map +1 -0
- package/dist/main/index.cjs +1637 -0
- package/dist/main/index.cjs.map +1 -0
- package/dist/main/index.d.cts +283 -0
- package/dist/main/index.d.ts +283 -0
- package/dist/main/index.mjs +1599 -0
- package/dist/main/index.mjs.map +1 -0
- package/dist/messages-DYY0MpGh.d.ts +76 -0
- package/dist/messages-ifub9bae.d.cts +76 -0
- package/dist/overlay/assets/index-7dVRGn83.css +1 -0
- package/dist/overlay/assets/index-CJf5iC1U.js +2 -0
- package/dist/overlay/assets/index-CJf5iC1U.js.map +1 -0
- package/dist/overlay/index.html +316 -0
- package/dist/overlay/preload.cjs +97 -0
- package/dist/overlay/preload.cjs.map +1 -0
- package/dist/overlay/preload.d.cts +16 -0
- package/dist/overlay/preload.d.ts +16 -0
- package/dist/overlay/preload.mjs +80 -0
- package/dist/overlay/preload.mjs.map +1 -0
- package/dist/preload/auto.cjs +24 -0
- package/dist/preload/auto.cjs.map +1 -0
- package/dist/preload/auto.d.cts +2 -0
- package/dist/preload/auto.d.ts +2 -0
- package/dist/preload/auto.mjs +22 -0
- package/dist/preload/auto.mjs.map +1 -0
- package/dist/preload/index.cjs +46 -0
- package/dist/preload/index.cjs.map +1 -0
- package/dist/preload/index.d.cts +18 -0
- package/dist/preload/index.d.ts +18 -0
- package/dist/preload/index.mjs +19 -0
- package/dist/preload/index.mjs.map +1 -0
- package/dist/types/index.cjs +19 -0
- package/dist/types/index.cjs.map +1 -0
- package/dist/types/index.d.cts +92 -0
- package/dist/types/index.d.ts +92 -0
- package/dist/types/index.mjs +1 -0
- package/dist/types/index.mjs.map +1 -0
- package/package.json +158 -0
|
@@ -0,0 +1,1599 @@
|
|
|
1
|
+
// node_modules/.pnpm/tsup@8.5.1_jiti@2.7.0_postc_3ddeda64d5f52f6813db2d2873a3e4a1/node_modules/tsup/assets/esm_shims.js
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
var getFilename = () => fileURLToPath(import.meta.url);
|
|
5
|
+
var getDirname = () => path.dirname(getFilename());
|
|
6
|
+
var __dirname = /* @__PURE__ */ getDirname();
|
|
7
|
+
|
|
8
|
+
// src/electron/main/screenshot-manager.ts
|
|
9
|
+
import { randomUUID } from "crypto";
|
|
10
|
+
import { ipcMain as electronIpcMain, webContents as webContents2 } from "electron";
|
|
11
|
+
|
|
12
|
+
// src/electron/main/diagnostics.ts
|
|
13
|
+
function emitScreenshotDiagnostic(listener, event) {
|
|
14
|
+
try {
|
|
15
|
+
listener?.(event);
|
|
16
|
+
} catch {
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// src/electron/main/electron-capture-adapter.ts
|
|
21
|
+
import { desktopCapturer, screen, systemPreferences } from "electron";
|
|
22
|
+
|
|
23
|
+
// src/electron/protocol/limits.ts
|
|
24
|
+
var DEFAULT_SCREENSHOT_RESOURCE_LIMITS = Object.freeze({
|
|
25
|
+
maxCapturePixels: 64 * 1024 * 1024,
|
|
26
|
+
maxCaptureDataUrlBytes: 192 * 1024 * 1024,
|
|
27
|
+
maxOutputBytes: 64 * 1024 * 1024
|
|
28
|
+
});
|
|
29
|
+
var HARD_SCREENSHOT_RESOURCE_LIMITS = Object.freeze({
|
|
30
|
+
maxCapturePixels: 128 * 1024 * 1024,
|
|
31
|
+
maxCaptureDataUrlBytes: 256 * 1024 * 1024,
|
|
32
|
+
maxOutputBytes: 256 * 1024 * 1024
|
|
33
|
+
});
|
|
34
|
+
function resolveScreenshotResourceLimits(options = {}) {
|
|
35
|
+
return {
|
|
36
|
+
maxCapturePixels: resolveLimit("maxCapturePixels", options.maxCapturePixels),
|
|
37
|
+
maxCaptureDataUrlBytes: resolveLimit(
|
|
38
|
+
"maxCaptureDataUrlBytes",
|
|
39
|
+
options.maxCaptureDataUrlBytes
|
|
40
|
+
),
|
|
41
|
+
maxOutputBytes: resolveLimit("maxOutputBytes", options.maxOutputBytes)
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function findCapturedFrameLimitViolation(frame, limits) {
|
|
45
|
+
const { width, height } = frame.pixelSize;
|
|
46
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0 || width * height > limits.maxCapturePixels) {
|
|
47
|
+
return `Captured frame exceeds the ${limits.maxCapturePixels} pixel limit.`;
|
|
48
|
+
}
|
|
49
|
+
if (frame.dataUrl.length > limits.maxCaptureDataUrlBytes) {
|
|
50
|
+
return `Captured frame exceeds the ${limits.maxCaptureDataUrlBytes} byte Data URL limit.`;
|
|
51
|
+
}
|
|
52
|
+
return void 0;
|
|
53
|
+
}
|
|
54
|
+
function resolveLimit(key, configuredValue) {
|
|
55
|
+
const value = configuredValue ?? DEFAULT_SCREENSHOT_RESOURCE_LIMITS[key];
|
|
56
|
+
if (!Number.isSafeInteger(value) || value <= 0 || value > HARD_SCREENSHOT_RESOURCE_LIMITS[key]) {
|
|
57
|
+
throw new TypeError(
|
|
58
|
+
`[electron-snapora] ${key} must be a positive safe integer no greater than ${HARD_SCREENSHOT_RESOURCE_LIMITS[key]}.`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/electron/main/errors.ts
|
|
65
|
+
var ScreenshotError = class extends Error {
|
|
66
|
+
code;
|
|
67
|
+
constructor(code, message, options) {
|
|
68
|
+
super(message, options);
|
|
69
|
+
this.name = "ScreenshotError";
|
|
70
|
+
this.code = code;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
function toScreenshotFailure(error, fallbackCode = "CAPTURE_FAILED") {
|
|
74
|
+
if (error instanceof ScreenshotError) {
|
|
75
|
+
return {
|
|
76
|
+
status: "failed",
|
|
77
|
+
code: error.code,
|
|
78
|
+
message: error.message
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
status: "failed",
|
|
83
|
+
code: fallbackCode,
|
|
84
|
+
message: error instanceof Error ? error.message : "Unknown screenshot error."
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// src/electron/main/electron-capture-adapter.ts
|
|
89
|
+
var ElectronCaptureAdapter = class {
|
|
90
|
+
#desktopCapturer;
|
|
91
|
+
#screen;
|
|
92
|
+
#platform;
|
|
93
|
+
#getScreenPermissionStatus;
|
|
94
|
+
#resourceLimits;
|
|
95
|
+
constructor(options = {}) {
|
|
96
|
+
this.#desktopCapturer = options.desktopCapturer ?? desktopCapturer;
|
|
97
|
+
this.#screen = options.screen ?? screen;
|
|
98
|
+
this.#platform = options.platform ?? process.platform;
|
|
99
|
+
this.#getScreenPermissionStatus = options.getScreenPermissionStatus ?? (() => systemPreferences.getMediaAccessStatus("screen"));
|
|
100
|
+
this.#resourceLimits = resolveScreenshotResourceLimits(options.resourceLimits);
|
|
101
|
+
}
|
|
102
|
+
resolveTargetDisplay(options = {}) {
|
|
103
|
+
this.#assertPermission();
|
|
104
|
+
return this.#toCaptureDisplay(this.#resolveDisplay(options.display ?? "cursor"));
|
|
105
|
+
}
|
|
106
|
+
async capture(options = {}, targetDisplay) {
|
|
107
|
+
this.#assertPermission();
|
|
108
|
+
const display = targetDisplay ? this.#resolveDisplay(targetDisplay.id) : this.#resolveDisplay(options.display ?? "cursor");
|
|
109
|
+
const pixelSize = {
|
|
110
|
+
width: Math.max(1, Math.round(display.bounds.width * display.scaleFactor)),
|
|
111
|
+
height: Math.max(1, Math.round(display.bounds.height * display.scaleFactor))
|
|
112
|
+
};
|
|
113
|
+
this.#assertRequestedPixelSize(pixelSize);
|
|
114
|
+
let sources;
|
|
115
|
+
try {
|
|
116
|
+
sources = await this.#desktopCapturer.getSources({
|
|
117
|
+
types: ["screen"],
|
|
118
|
+
thumbnailSize: pixelSize,
|
|
119
|
+
fetchWindowIcons: false
|
|
120
|
+
});
|
|
121
|
+
} catch (error) {
|
|
122
|
+
this.#assertPermission();
|
|
123
|
+
throw new ScreenshotError(
|
|
124
|
+
"CAPTURE_FAILED",
|
|
125
|
+
"Electron failed to capture the screen.",
|
|
126
|
+
{
|
|
127
|
+
cause: error
|
|
128
|
+
}
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
const source = sources.find(
|
|
132
|
+
(candidate) => candidate.display_id === String(display.id)
|
|
133
|
+
);
|
|
134
|
+
if (!source) {
|
|
135
|
+
this.#assertPermission();
|
|
136
|
+
throw new ScreenshotError(
|
|
137
|
+
"DISPLAY_NOT_FOUND",
|
|
138
|
+
`No desktop capture source matched display ${display.id}.`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
if (source.thumbnail.isEmpty()) {
|
|
142
|
+
this.#assertPermission();
|
|
143
|
+
throw new ScreenshotError(
|
|
144
|
+
"CAPTURE_FAILED",
|
|
145
|
+
"Electron returned an empty screen image."
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const actualPixelSize = source.thumbnail.getSize();
|
|
149
|
+
if (actualPixelSize.width <= 0 || actualPixelSize.height <= 0) {
|
|
150
|
+
throw new ScreenshotError(
|
|
151
|
+
"CAPTURE_FAILED",
|
|
152
|
+
"Electron returned an invalid screen image size."
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
const frame = {
|
|
156
|
+
display: this.#toCaptureDisplay(display),
|
|
157
|
+
dataUrl: source.thumbnail.toDataURL(),
|
|
158
|
+
pixelSize: actualPixelSize
|
|
159
|
+
};
|
|
160
|
+
const violation = findCapturedFrameLimitViolation(frame, this.#resourceLimits);
|
|
161
|
+
if (violation) {
|
|
162
|
+
throw new ScreenshotError("RESOURCE_LIMIT_EXCEEDED", violation);
|
|
163
|
+
}
|
|
164
|
+
return [frame];
|
|
165
|
+
}
|
|
166
|
+
#assertRequestedPixelSize(pixelSize) {
|
|
167
|
+
if (pixelSize.width * pixelSize.height > this.#resourceLimits.maxCapturePixels) {
|
|
168
|
+
throw new ScreenshotError(
|
|
169
|
+
"RESOURCE_LIMIT_EXCEEDED",
|
|
170
|
+
`Requested capture exceeds the ${this.#resourceLimits.maxCapturePixels} pixel limit.`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
#assertPermission() {
|
|
175
|
+
if (this.#platform !== "darwin") {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const status = this.#getScreenPermissionStatus();
|
|
179
|
+
if (status === "denied" || status === "restricted") {
|
|
180
|
+
throw new ScreenshotError(
|
|
181
|
+
"PERMISSION_DENIED",
|
|
182
|
+
"Screen recording permission is required to capture the display."
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
#resolveDisplay(requestedDisplay) {
|
|
187
|
+
if (requestedDisplay === "cursor") {
|
|
188
|
+
return this.#screen.getDisplayNearestPoint(this.#screen.getCursorScreenPoint());
|
|
189
|
+
}
|
|
190
|
+
if (requestedDisplay === "primary") {
|
|
191
|
+
return this.#screen.getPrimaryDisplay();
|
|
192
|
+
}
|
|
193
|
+
const display = this.#screen.getAllDisplays().find((candidate) => String(candidate.id) === requestedDisplay);
|
|
194
|
+
if (!display) {
|
|
195
|
+
throw new ScreenshotError(
|
|
196
|
+
"DISPLAY_NOT_FOUND",
|
|
197
|
+
`Display ${requestedDisplay} is not available.`
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
return display;
|
|
201
|
+
}
|
|
202
|
+
#toCaptureDisplay(display) {
|
|
203
|
+
return {
|
|
204
|
+
id: String(display.id),
|
|
205
|
+
bounds: { ...display.bounds },
|
|
206
|
+
scaleFactor: display.scaleFactor
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// src/electron/main/electron-output-adapter.ts
|
|
212
|
+
import { writeFile } from "fs/promises";
|
|
213
|
+
import { BrowserWindow, clipboard, dialog, nativeImage, webContents } from "electron";
|
|
214
|
+
var ElectronOutputAdapter = class {
|
|
215
|
+
#saveFile;
|
|
216
|
+
#copyImage;
|
|
217
|
+
#createSuggestedName;
|
|
218
|
+
constructor(options = {}) {
|
|
219
|
+
this.#saveFile = options.saveFile ?? savePngWithDialog;
|
|
220
|
+
this.#copyImage = options.copyImage ?? copyPngToClipboard;
|
|
221
|
+
this.#createSuggestedName = options.createSuggestedName ?? createSuggestedName;
|
|
222
|
+
}
|
|
223
|
+
async execute(payload, context) {
|
|
224
|
+
if (payload.action === "copy") {
|
|
225
|
+
this.#copyImage(payload.result.data);
|
|
226
|
+
return { status: "completed", action: "copy" };
|
|
227
|
+
}
|
|
228
|
+
const filePath = await this.#saveFile(
|
|
229
|
+
payload.result.data,
|
|
230
|
+
this.#createSuggestedName(),
|
|
231
|
+
context.senderWebContentsId
|
|
232
|
+
);
|
|
233
|
+
return filePath ? { status: "completed", action: "save", filePath } : { status: "cancelled" };
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
async function savePngWithDialog(data, suggestedName, senderWebContentsId) {
|
|
237
|
+
const sender = webContents.fromId(senderWebContentsId);
|
|
238
|
+
const owner = sender ? BrowserWindow.fromWebContents(sender) : null;
|
|
239
|
+
const options = {
|
|
240
|
+
title: "Save screenshot",
|
|
241
|
+
defaultPath: suggestedName,
|
|
242
|
+
filters: [{ name: "PNG image", extensions: ["png"] }],
|
|
243
|
+
properties: ["createDirectory", "showOverwriteConfirmation"]
|
|
244
|
+
};
|
|
245
|
+
const result = owner ? await dialog.showSaveDialog(owner, options) : await dialog.showSaveDialog(options);
|
|
246
|
+
if (result.canceled || !result.filePath) {
|
|
247
|
+
return void 0;
|
|
248
|
+
}
|
|
249
|
+
await writeFile(result.filePath, data);
|
|
250
|
+
return result.filePath;
|
|
251
|
+
}
|
|
252
|
+
function copyPngToClipboard(data) {
|
|
253
|
+
const image = nativeImage.createFromBuffer(Buffer.from(data));
|
|
254
|
+
if (image.isEmpty()) {
|
|
255
|
+
throw new Error("The exported PNG could not be decoded for the clipboard.");
|
|
256
|
+
}
|
|
257
|
+
clipboard.writeImage(image);
|
|
258
|
+
}
|
|
259
|
+
function createSuggestedName() {
|
|
260
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replaceAll(":", "-").replace(/\.\d{3}Z$/, "");
|
|
261
|
+
return `screenshot-${timestamp}.png`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// src/electron/main/overlay-window.ts
|
|
265
|
+
import { BrowserWindow as BrowserWindow2 } from "electron";
|
|
266
|
+
|
|
267
|
+
// src/electron/protocol/channels.ts
|
|
268
|
+
var DEFAULT_HOST_CAPTURE_CHANNEL = "electron-snapora:host:capture";
|
|
269
|
+
var DEFAULT_HOST_CANCEL_CHANNEL = "electron-snapora:host:cancel";
|
|
270
|
+
var OVERLAY_CHANNELS = {
|
|
271
|
+
ready: "electron-snapora:overlay:ready",
|
|
272
|
+
prepared: "electron-snapora:overlay:prepared",
|
|
273
|
+
initialize: "electron-snapora:overlay:initialize",
|
|
274
|
+
feedback: "electron-snapora:overlay:feedback",
|
|
275
|
+
feedbackReady: "electron-snapora:overlay:feedback-ready",
|
|
276
|
+
confirm: "electron-snapora:overlay:confirm",
|
|
277
|
+
cancel: "electron-snapora:overlay:cancel",
|
|
278
|
+
error: "electron-snapora:overlay:error",
|
|
279
|
+
output: "electron-snapora:overlay:output"
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// src/electron/main/resource-paths.ts
|
|
283
|
+
import { existsSync } from "fs";
|
|
284
|
+
import { resolve } from "path";
|
|
285
|
+
var PackagedResourceError = class extends Error {
|
|
286
|
+
missingResources;
|
|
287
|
+
constructor(missingResources) {
|
|
288
|
+
const details = missingResources.map((resource) => `${resource.label}: ${resource.path}`).join("; ");
|
|
289
|
+
super(
|
|
290
|
+
`[electron-snapora] Packaged resource missing (${details}). Keep electron-snapora external in the Electron main-process bundle and include its complete dist directory in the application package.`
|
|
291
|
+
);
|
|
292
|
+
this.name = "PackagedResourceError";
|
|
293
|
+
this.missingResources = missingResources.map((resource) => ({ ...resource }));
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
function resolveOverlayResources(mainModuleDirectory = __dirname, resourceExists = existsSync) {
|
|
297
|
+
const overlayDirectory = resolve(mainModuleDirectory, "..", "overlay");
|
|
298
|
+
const resources = {
|
|
299
|
+
htmlPath: resolve(overlayDirectory, "index.html"),
|
|
300
|
+
preloadPath: resolve(overlayDirectory, "preload.cjs")
|
|
301
|
+
};
|
|
302
|
+
assertOverlayResources(resources, resourceExists);
|
|
303
|
+
return resources;
|
|
304
|
+
}
|
|
305
|
+
function resolveHostPreloadPath(mainModuleDirectory = __dirname, resourceExists = existsSync) {
|
|
306
|
+
const preloadPath = resolve(mainModuleDirectory, "..", "preload", "auto.cjs");
|
|
307
|
+
assertPackagedResources(
|
|
308
|
+
[{ label: "host preload", path: preloadPath }],
|
|
309
|
+
resourceExists
|
|
310
|
+
);
|
|
311
|
+
return preloadPath;
|
|
312
|
+
}
|
|
313
|
+
function assertOverlayResources(resources, resourceExists = existsSync) {
|
|
314
|
+
assertPackagedResources(
|
|
315
|
+
[
|
|
316
|
+
{ label: "overlay HTML", path: resources.htmlPath },
|
|
317
|
+
{ label: "overlay preload", path: resources.preloadPath }
|
|
318
|
+
],
|
|
319
|
+
resourceExists
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
function assertPackagedResources(resources, resourceExists) {
|
|
323
|
+
const missing = resources.filter((resource) => !resourceExists(resource.path));
|
|
324
|
+
if (missing.length === 0) {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
throw new PackagedResourceError(missing);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// src/electron/main/overlay-window.ts
|
|
331
|
+
var OverlayWindow = class {
|
|
332
|
+
#resources;
|
|
333
|
+
#window;
|
|
334
|
+
#createWindow;
|
|
335
|
+
#bounds;
|
|
336
|
+
#platform;
|
|
337
|
+
#supportsInvisiblePriming;
|
|
338
|
+
#primed = false;
|
|
339
|
+
#feedbackWindow;
|
|
340
|
+
#feedbackTimer;
|
|
341
|
+
constructor(options) {
|
|
342
|
+
this.#resources = options.resources ?? resolveOverlayResources(void 0, options.resourceExists);
|
|
343
|
+
assertOverlayResources(this.#resources, options.resourceExists);
|
|
344
|
+
this.#createWindow = options.createWindow ?? ((windowOptions) => new BrowserWindow2(windowOptions));
|
|
345
|
+
const { bounds } = options.display;
|
|
346
|
+
this.#bounds = bounds;
|
|
347
|
+
this.#platform = options.platform ?? process.platform;
|
|
348
|
+
this.#supportsInvisiblePriming = ["win32", "darwin"].includes(this.#platform);
|
|
349
|
+
this.#window = this.#createWindow({
|
|
350
|
+
x: bounds.x,
|
|
351
|
+
y: bounds.y,
|
|
352
|
+
width: bounds.width,
|
|
353
|
+
height: bounds.height,
|
|
354
|
+
useContentSize: true,
|
|
355
|
+
frame: false,
|
|
356
|
+
hasShadow: false,
|
|
357
|
+
resizable: false,
|
|
358
|
+
movable: false,
|
|
359
|
+
minimizable: false,
|
|
360
|
+
maximizable: false,
|
|
361
|
+
// macOS 简易全屏要求窗口具备全屏能力;无边框窗口没有可供用户切换的系统按钮。
|
|
362
|
+
fullscreenable: this.#platform === "darwin",
|
|
363
|
+
skipTaskbar: true,
|
|
364
|
+
alwaysOnTop: true,
|
|
365
|
+
transparent: false,
|
|
366
|
+
show: false,
|
|
367
|
+
opacity: this.#supportsInvisiblePriming ? 0 : 1,
|
|
368
|
+
paintWhenInitiallyHidden: true,
|
|
369
|
+
autoHideMenuBar: true,
|
|
370
|
+
backgroundColor: "#000000",
|
|
371
|
+
...this.#platform === "win32" ? { roundedCorners: false, thickFrame: false } : {},
|
|
372
|
+
...this.#platform === "darwin" ? {
|
|
373
|
+
enableLargerThanScreen: true,
|
|
374
|
+
hiddenInMissionControl: true,
|
|
375
|
+
simpleFullscreen: true
|
|
376
|
+
} : {},
|
|
377
|
+
webPreferences: {
|
|
378
|
+
preload: this.#resources.preloadPath,
|
|
379
|
+
nodeIntegration: false,
|
|
380
|
+
contextIsolation: true,
|
|
381
|
+
sandbox: true,
|
|
382
|
+
zoomFactor: 1
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
this.#configureMacWorkspaceVisibility(this.#window);
|
|
386
|
+
}
|
|
387
|
+
get webContentsId() {
|
|
388
|
+
return this.#window.webContents.id;
|
|
389
|
+
}
|
|
390
|
+
async load() {
|
|
391
|
+
await this.#window.loadFile(this.#resources.htmlPath);
|
|
392
|
+
}
|
|
393
|
+
sendInitialize(payload) {
|
|
394
|
+
this.#window.webContents.send(OVERLAY_CHANNELS.initialize, payload);
|
|
395
|
+
}
|
|
396
|
+
/** 先以全透明状态进入桌面合成器,隐藏 Windows/macOS 的窗口出场和大图首帧栅格化。 */
|
|
397
|
+
prime() {
|
|
398
|
+
if (!this.#window.isDestroyed() && this.#supportsInvisiblePriming) {
|
|
399
|
+
this.#raiseAboveOtherWindows();
|
|
400
|
+
this.#window.showInactive();
|
|
401
|
+
if (this.#platform !== "darwin") {
|
|
402
|
+
this.#window.setBounds(this.#bounds, false);
|
|
403
|
+
}
|
|
404
|
+
this.#window.moveTop();
|
|
405
|
+
this.#primed = true;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
reveal() {
|
|
409
|
+
if (this.#window.isDestroyed()) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
this.#raiseAboveOtherWindows();
|
|
413
|
+
if (this.#primed) {
|
|
414
|
+
this.#window.setOpacity(1);
|
|
415
|
+
this.#window.focus();
|
|
416
|
+
} else {
|
|
417
|
+
this.#window.show();
|
|
418
|
+
}
|
|
419
|
+
this.#window.moveTop();
|
|
420
|
+
}
|
|
421
|
+
/** 复制完成后销毁全屏截图层,改用独立的小窗口显示鼠标穿透提示。 */
|
|
422
|
+
showCopyFeedback(durationMs, options) {
|
|
423
|
+
if (!this.#window.isDestroyed()) {
|
|
424
|
+
this.#window.destroy();
|
|
425
|
+
}
|
|
426
|
+
this.#destroyFeedbackWindow();
|
|
427
|
+
const width = Math.min(360, this.#bounds.width);
|
|
428
|
+
const height = Math.min(72, this.#bounds.height);
|
|
429
|
+
const feedbackWindow = this.#createWindow({
|
|
430
|
+
x: this.#bounds.x + Math.round((this.#bounds.width - width) / 2),
|
|
431
|
+
y: this.#bounds.y + Math.min(24, Math.max(0, this.#bounds.height - height)),
|
|
432
|
+
width,
|
|
433
|
+
height,
|
|
434
|
+
useContentSize: true,
|
|
435
|
+
frame: false,
|
|
436
|
+
hasShadow: false,
|
|
437
|
+
resizable: false,
|
|
438
|
+
movable: false,
|
|
439
|
+
minimizable: false,
|
|
440
|
+
maximizable: false,
|
|
441
|
+
fullscreenable: false,
|
|
442
|
+
focusable: false,
|
|
443
|
+
skipTaskbar: true,
|
|
444
|
+
alwaysOnTop: true,
|
|
445
|
+
transparent: true,
|
|
446
|
+
show: false,
|
|
447
|
+
paintWhenInitiallyHidden: true,
|
|
448
|
+
autoHideMenuBar: true,
|
|
449
|
+
backgroundColor: "#00000000",
|
|
450
|
+
webPreferences: {
|
|
451
|
+
preload: this.#resources.preloadPath,
|
|
452
|
+
nodeIntegration: false,
|
|
453
|
+
contextIsolation: true,
|
|
454
|
+
sandbox: true,
|
|
455
|
+
zoomFactor: 1
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
this.#feedbackWindow = feedbackWindow;
|
|
459
|
+
this.#configureMacWorkspaceVisibility(feedbackWindow);
|
|
460
|
+
feedbackWindow.setIgnoreMouseEvents(true);
|
|
461
|
+
const handleIpcMessage = (_event, channel) => {
|
|
462
|
+
if (channel !== OVERLAY_CHANNELS.feedbackReady) {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
feedbackWindow.webContents.removeListener("ipc-message", handleIpcMessage);
|
|
466
|
+
if (feedbackWindow.isDestroyed() || this.#feedbackWindow !== feedbackWindow) {
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
this.#clearFeedbackTimer();
|
|
470
|
+
this.#raiseAboveOtherWindows(feedbackWindow);
|
|
471
|
+
feedbackWindow.showInactive();
|
|
472
|
+
feedbackWindow.moveTop();
|
|
473
|
+
this.#feedbackTimer = setTimeout(
|
|
474
|
+
() => this.#destroyFeedbackWindow(feedbackWindow),
|
|
475
|
+
durationMs
|
|
476
|
+
);
|
|
477
|
+
this.#feedbackTimer.unref?.();
|
|
478
|
+
};
|
|
479
|
+
feedbackWindow.webContents.on("ipc-message", handleIpcMessage);
|
|
480
|
+
this.#feedbackTimer = setTimeout(
|
|
481
|
+
() => this.#destroyFeedbackWindow(feedbackWindow),
|
|
482
|
+
2e3
|
|
483
|
+
);
|
|
484
|
+
this.#feedbackTimer.unref?.();
|
|
485
|
+
void feedbackWindow.loadFile(this.#resources.htmlPath).then(() => {
|
|
486
|
+
if (!feedbackWindow.isDestroyed() && this.#feedbackWindow === feedbackWindow) {
|
|
487
|
+
feedbackWindow.webContents.send(OVERLAY_CHANNELS.feedback, {
|
|
488
|
+
kind: "copy",
|
|
489
|
+
durationMs,
|
|
490
|
+
options
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
}).catch(() => this.#destroyFeedbackWindow(feedbackWindow));
|
|
494
|
+
}
|
|
495
|
+
/** 截图层必须高于普通置顶窗口;Windows/macOS 使用系统支持的最高标准层级。 */
|
|
496
|
+
#raiseAboveOtherWindows(window = this.#window) {
|
|
497
|
+
if (this.#platform === "win32" || this.#platform === "darwin") {
|
|
498
|
+
window.setAlwaysOnTop(true, "screen-saver");
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
window.setAlwaysOnTop(true);
|
|
502
|
+
}
|
|
503
|
+
/** 让截图层和复制提示在 macOS 全屏 Space 中也保持可见。 */
|
|
504
|
+
#configureMacWorkspaceVisibility(window) {
|
|
505
|
+
if (this.#platform === "darwin") {
|
|
506
|
+
window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
destroy() {
|
|
510
|
+
this.#destroyFeedbackWindow();
|
|
511
|
+
if (!this.#window.isDestroyed()) {
|
|
512
|
+
this.#window.destroy();
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
#clearFeedbackTimer() {
|
|
516
|
+
if (this.#feedbackTimer) {
|
|
517
|
+
clearTimeout(this.#feedbackTimer);
|
|
518
|
+
this.#feedbackTimer = void 0;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
#destroyFeedbackWindow(window = this.#feedbackWindow) {
|
|
522
|
+
this.#clearFeedbackTimer();
|
|
523
|
+
if (window && !window.isDestroyed()) {
|
|
524
|
+
window.destroy();
|
|
525
|
+
}
|
|
526
|
+
if (this.#feedbackWindow === window) {
|
|
527
|
+
this.#feedbackWindow = void 0;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
onClosed(listener) {
|
|
531
|
+
this.#window.on("closed", listener);
|
|
532
|
+
return () => {
|
|
533
|
+
if (!this.#window.isDestroyed()) {
|
|
534
|
+
this.#window.removeListener("closed", listener);
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
onRendererGone(listener) {
|
|
539
|
+
const handleGone = () => {
|
|
540
|
+
listener();
|
|
541
|
+
};
|
|
542
|
+
this.#window.webContents.on("render-process-gone", handleGone);
|
|
543
|
+
return () => {
|
|
544
|
+
if (!this.#window.isDestroyed()) {
|
|
545
|
+
this.#window.webContents.removeListener("render-process-gone", handleGone);
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
// src/electron/protocol/messages.ts
|
|
552
|
+
var SCREENSHOT_PROTOCOL_VERSION = 1;
|
|
553
|
+
|
|
554
|
+
// src/electron/protocol/validators.ts
|
|
555
|
+
var SCREENSHOT_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
556
|
+
"CAPTURE_BUSY",
|
|
557
|
+
"INVALID_REQUEST",
|
|
558
|
+
"RESOURCE_LIMIT_EXCEEDED",
|
|
559
|
+
"PERMISSION_DENIED",
|
|
560
|
+
"DISPLAY_NOT_FOUND",
|
|
561
|
+
"CAPTURE_FAILED",
|
|
562
|
+
"OVERLAY_LOAD_FAILED",
|
|
563
|
+
"EXPORT_FAILED",
|
|
564
|
+
"INVALID_RESULT",
|
|
565
|
+
"UNSUPPORTED_PLATFORM"
|
|
566
|
+
]);
|
|
567
|
+
var SCREENSHOT_TOOLS = /* @__PURE__ */ new Set([
|
|
568
|
+
"rectangle",
|
|
569
|
+
"ellipse",
|
|
570
|
+
"arrow",
|
|
571
|
+
"brush",
|
|
572
|
+
"text",
|
|
573
|
+
"mosaic"
|
|
574
|
+
]);
|
|
575
|
+
var SCREENSHOT_OPTION_KEYS = /* @__PURE__ */ new Set([
|
|
576
|
+
"display",
|
|
577
|
+
"tools",
|
|
578
|
+
"defaultTool",
|
|
579
|
+
"locale",
|
|
580
|
+
"messages",
|
|
581
|
+
"theme"
|
|
582
|
+
]);
|
|
583
|
+
var SCREENSHOT_THEME_COLOR_KEYS = [
|
|
584
|
+
"accentColor",
|
|
585
|
+
"accentForegroundColor",
|
|
586
|
+
"maskColor",
|
|
587
|
+
"toolbarBackground",
|
|
588
|
+
"toolbarForeground",
|
|
589
|
+
"toolbarBorderColor",
|
|
590
|
+
"toolbarHoverBackground",
|
|
591
|
+
"tooltipBackground",
|
|
592
|
+
"tooltipForeground",
|
|
593
|
+
"destructiveColor",
|
|
594
|
+
"warningColor",
|
|
595
|
+
"warningForegroundColor",
|
|
596
|
+
"selectionHandleColor"
|
|
597
|
+
];
|
|
598
|
+
var SCREENSHOT_THEME_KEYS = /* @__PURE__ */ new Set(["mode", ...SCREENSHOT_THEME_COLOR_KEYS]);
|
|
599
|
+
var SCREENSHOT_MESSAGE_KEYS = [
|
|
600
|
+
"preparing",
|
|
601
|
+
"instruction",
|
|
602
|
+
"exporting",
|
|
603
|
+
"copied",
|
|
604
|
+
"saveCancelled",
|
|
605
|
+
"cancel",
|
|
606
|
+
"save",
|
|
607
|
+
"confirm",
|
|
608
|
+
"select",
|
|
609
|
+
"rectangle",
|
|
610
|
+
"ellipse",
|
|
611
|
+
"arrow",
|
|
612
|
+
"brush",
|
|
613
|
+
"text",
|
|
614
|
+
"mosaic",
|
|
615
|
+
"undo",
|
|
616
|
+
"redo",
|
|
617
|
+
"color",
|
|
618
|
+
"lineWidth",
|
|
619
|
+
"fontSize",
|
|
620
|
+
"annotationCanvas",
|
|
621
|
+
"selection",
|
|
622
|
+
"actions",
|
|
623
|
+
"annotationTools",
|
|
624
|
+
"history",
|
|
625
|
+
"annotationStyle",
|
|
626
|
+
"outputActions",
|
|
627
|
+
"annotationText"
|
|
628
|
+
];
|
|
629
|
+
var SCREENSHOT_MESSAGE_KEY_SET = new Set(SCREENSHOT_MESSAGE_KEYS);
|
|
630
|
+
function isRecord(value) {
|
|
631
|
+
return typeof value === "object" && value !== null;
|
|
632
|
+
}
|
|
633
|
+
function hasOnlyKeys(value, allowedKeys) {
|
|
634
|
+
return Object.keys(value).every((key) => allowedKeys.has(key));
|
|
635
|
+
}
|
|
636
|
+
function isBoundedString(value, maximumLength) {
|
|
637
|
+
return typeof value === "string" && value.length > 0 && value.length <= maximumLength;
|
|
638
|
+
}
|
|
639
|
+
function parseScreenshotOptions(value) {
|
|
640
|
+
if (value === void 0) {
|
|
641
|
+
return { success: true, value: {} };
|
|
642
|
+
}
|
|
643
|
+
if (!isRecord(value) || !hasOnlyKeys(value, SCREENSHOT_OPTION_KEYS)) {
|
|
644
|
+
return {
|
|
645
|
+
success: false,
|
|
646
|
+
message: "Screenshot options must be a plain supported object."
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
const parsed = {};
|
|
650
|
+
if (value.display !== void 0) {
|
|
651
|
+
if (!isBoundedString(value.display, 128)) {
|
|
652
|
+
return {
|
|
653
|
+
success: false,
|
|
654
|
+
message: "Screenshot display must be a non-empty string."
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
parsed.display = value.display;
|
|
658
|
+
}
|
|
659
|
+
if (value.tools !== void 0) {
|
|
660
|
+
if (!Array.isArray(value.tools) || value.tools.length > SCREENSHOT_TOOLS.size || !value.tools.every(
|
|
661
|
+
(tool) => typeof tool === "string" && SCREENSHOT_TOOLS.has(tool)
|
|
662
|
+
)) {
|
|
663
|
+
return {
|
|
664
|
+
success: false,
|
|
665
|
+
message: "Screenshot tools contain an unsupported value."
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
parsed.tools = [...new Set(value.tools)];
|
|
669
|
+
}
|
|
670
|
+
if (value.defaultTool !== void 0) {
|
|
671
|
+
if (value.defaultTool !== "select" && (typeof value.defaultTool !== "string" || !SCREENSHOT_TOOLS.has(value.defaultTool))) {
|
|
672
|
+
return { success: false, message: "Screenshot defaultTool is unsupported." };
|
|
673
|
+
}
|
|
674
|
+
if (value.defaultTool !== "select" && parsed.tools && !parsed.tools.includes(value.defaultTool)) {
|
|
675
|
+
return {
|
|
676
|
+
success: false,
|
|
677
|
+
message: "Screenshot defaultTool must be enabled in tools."
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
parsed.defaultTool = value.defaultTool;
|
|
681
|
+
}
|
|
682
|
+
if (value.locale !== void 0) {
|
|
683
|
+
if (value.locale !== "zh-CN" && value.locale !== "en-US") {
|
|
684
|
+
return { success: false, message: "Screenshot locale is unsupported." };
|
|
685
|
+
}
|
|
686
|
+
parsed.locale = value.locale;
|
|
687
|
+
}
|
|
688
|
+
if (value.messages !== void 0) {
|
|
689
|
+
const messages = parseScreenshotMessages(value.messages);
|
|
690
|
+
if (!messages) {
|
|
691
|
+
return { success: false, message: "Screenshot messages are invalid." };
|
|
692
|
+
}
|
|
693
|
+
parsed.messages = messages;
|
|
694
|
+
}
|
|
695
|
+
if (value.theme !== void 0) {
|
|
696
|
+
const theme = parseScreenshotTheme(value.theme);
|
|
697
|
+
if (!theme) {
|
|
698
|
+
return { success: false, message: "Screenshot theme is invalid." };
|
|
699
|
+
}
|
|
700
|
+
parsed.theme = theme;
|
|
701
|
+
}
|
|
702
|
+
return { success: true, value: parsed };
|
|
703
|
+
}
|
|
704
|
+
function parseScreenshotTheme(value) {
|
|
705
|
+
if (!isRecord(value) || !hasOnlyKeys(value, SCREENSHOT_THEME_KEYS)) {
|
|
706
|
+
return void 0;
|
|
707
|
+
}
|
|
708
|
+
const theme = {};
|
|
709
|
+
if (value.mode !== void 0) {
|
|
710
|
+
if (value.mode !== "dark" && value.mode !== "light") {
|
|
711
|
+
return void 0;
|
|
712
|
+
}
|
|
713
|
+
theme.mode = value.mode;
|
|
714
|
+
}
|
|
715
|
+
for (const key of SCREENSHOT_THEME_COLOR_KEYS) {
|
|
716
|
+
const color = value[key];
|
|
717
|
+
if (color === void 0) {
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
if (!isBoundedString(color, 128)) {
|
|
721
|
+
return void 0;
|
|
722
|
+
}
|
|
723
|
+
theme[key] = color;
|
|
724
|
+
}
|
|
725
|
+
return theme;
|
|
726
|
+
}
|
|
727
|
+
function parseScreenshotMessages(value) {
|
|
728
|
+
if (!isRecord(value) || !hasOnlyKeys(value, SCREENSHOT_MESSAGE_KEY_SET)) {
|
|
729
|
+
return void 0;
|
|
730
|
+
}
|
|
731
|
+
const messages = {};
|
|
732
|
+
for (const key of SCREENSHOT_MESSAGE_KEYS) {
|
|
733
|
+
const message = value[key];
|
|
734
|
+
if (message === void 0) {
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
if (!isBoundedString(message, 256)) {
|
|
738
|
+
return void 0;
|
|
739
|
+
}
|
|
740
|
+
messages[key] = message;
|
|
741
|
+
}
|
|
742
|
+
return messages;
|
|
743
|
+
}
|
|
744
|
+
function hasProtocolVersion(value) {
|
|
745
|
+
return value.protocolVersion === SCREENSHOT_PROTOCOL_VERSION;
|
|
746
|
+
}
|
|
747
|
+
function hasExpectedJob(value, expectedJobId) {
|
|
748
|
+
return value.jobId === expectedJobId;
|
|
749
|
+
}
|
|
750
|
+
function isFiniteNumber(value) {
|
|
751
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
752
|
+
}
|
|
753
|
+
function isBounds(value) {
|
|
754
|
+
if (!isRecord(value)) {
|
|
755
|
+
return false;
|
|
756
|
+
}
|
|
757
|
+
return isFiniteNumber(value.x) && isFiniteNumber(value.y) && isFiniteNumber(value.width) && isFiniteNumber(value.height) && value.width > 0 && value.height > 0;
|
|
758
|
+
}
|
|
759
|
+
function isImageResult(value, maxOutputBytes = HARD_SCREENSHOT_RESOURCE_LIMITS.maxOutputBytes) {
|
|
760
|
+
if (!isRecord(value) || value.status !== "completed") {
|
|
761
|
+
return false;
|
|
762
|
+
}
|
|
763
|
+
return value.data instanceof Uint8Array && value.data.byteLength > 0 && value.data.byteLength <= maxOutputBytes && value.mimeType === "image/png" && isBounds(value.bounds) && typeof value.displayId === "string" && value.displayId.length > 0;
|
|
764
|
+
}
|
|
765
|
+
function isOutputMetadata(value) {
|
|
766
|
+
if (!isRecord(value)) {
|
|
767
|
+
return false;
|
|
768
|
+
}
|
|
769
|
+
if (value.action === "copy") {
|
|
770
|
+
return true;
|
|
771
|
+
}
|
|
772
|
+
return value.action === "save" && typeof value.filePath === "string" && value.filePath.length > 0;
|
|
773
|
+
}
|
|
774
|
+
function isCompletedResult(value, maxOutputBytes) {
|
|
775
|
+
return isImageResult(value, maxOutputBytes) && isOutputMetadata(value.output);
|
|
776
|
+
}
|
|
777
|
+
function isReadyPayload(value) {
|
|
778
|
+
return isRecord(value) && hasProtocolVersion(value);
|
|
779
|
+
}
|
|
780
|
+
function isPreparedPayload(value, expectedJobId) {
|
|
781
|
+
return isRecord(value) && hasProtocolVersion(value) && hasExpectedJob(value, expectedJobId);
|
|
782
|
+
}
|
|
783
|
+
function isCompletePayload(value, expectedJobId, maxOutputBytes) {
|
|
784
|
+
return isRecord(value) && hasProtocolVersion(value) && hasExpectedJob(value, expectedJobId) && isCompletedResult(value.result, maxOutputBytes);
|
|
785
|
+
}
|
|
786
|
+
function isCancelPayload(value, expectedJobId) {
|
|
787
|
+
return isRecord(value) && hasProtocolVersion(value) && hasExpectedJob(value, expectedJobId);
|
|
788
|
+
}
|
|
789
|
+
function isErrorPayload(value, expectedJobId) {
|
|
790
|
+
return isRecord(value) && hasProtocolVersion(value) && hasExpectedJob(value, expectedJobId) && typeof value.code === "string" && SCREENSHOT_ERROR_CODES.has(value.code) && typeof value.message === "string" && value.message.length > 0;
|
|
791
|
+
}
|
|
792
|
+
function isOutputPayload(value, maxOutputBytes) {
|
|
793
|
+
if (!isRecord(value)) {
|
|
794
|
+
return false;
|
|
795
|
+
}
|
|
796
|
+
return hasProtocolVersion(value) && typeof value.jobId === "string" && value.jobId.length > 0 && (value.action === "save" || value.action === "copy") && isImageResult(value.result, maxOutputBytes);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// src/electron/main/output-action-router.ts
|
|
800
|
+
var ScreenshotOutputRouter = class {
|
|
801
|
+
#handlers = /* @__PURE__ */ new Map();
|
|
802
|
+
constructor(ipcMain) {
|
|
803
|
+
ipcMain.handle(OVERLAY_CHANNELS.output, this.#handleOutput);
|
|
804
|
+
}
|
|
805
|
+
register(senderWebContentsId, jobId, handler) {
|
|
806
|
+
this.#handlers.set(senderWebContentsId, { jobId, handler });
|
|
807
|
+
return () => {
|
|
808
|
+
const current = this.#handlers.get(senderWebContentsId);
|
|
809
|
+
if (current?.jobId === jobId) {
|
|
810
|
+
this.#handlers.delete(senderWebContentsId);
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
#handleOutput = async (event, payload) => {
|
|
815
|
+
if (!isOutputPayload(payload)) {
|
|
816
|
+
return {
|
|
817
|
+
status: "failed",
|
|
818
|
+
code: "INVALID_REQUEST",
|
|
819
|
+
message: "Invalid or oversized screenshot output request."
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
const active = this.#handlers.get(event.sender.id);
|
|
823
|
+
if (!active || active.jobId !== payload.jobId) {
|
|
824
|
+
return {
|
|
825
|
+
status: "failed",
|
|
826
|
+
code: "INVALID_REQUEST",
|
|
827
|
+
message: "Screenshot output task is not active."
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
try {
|
|
831
|
+
return await active.handler(payload, {
|
|
832
|
+
senderWebContentsId: event.sender.id
|
|
833
|
+
});
|
|
834
|
+
} catch (error) {
|
|
835
|
+
return {
|
|
836
|
+
status: "failed",
|
|
837
|
+
code: "EXPORT_FAILED",
|
|
838
|
+
message: error instanceof Error ? error.message : "Screenshot output failed."
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
};
|
|
843
|
+
var routers = /* @__PURE__ */ new WeakMap();
|
|
844
|
+
function getScreenshotOutputRouter(ipcMain) {
|
|
845
|
+
const key = ipcMain;
|
|
846
|
+
const existing = routers.get(key);
|
|
847
|
+
if (existing) {
|
|
848
|
+
return existing;
|
|
849
|
+
}
|
|
850
|
+
const router = new ScreenshotOutputRouter(ipcMain);
|
|
851
|
+
routers.set(key, router);
|
|
852
|
+
return router;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// src/electron/main/screenshot-session.ts
|
|
856
|
+
var ScreenshotSession = class {
|
|
857
|
+
#options;
|
|
858
|
+
#resourceLimits;
|
|
859
|
+
#state = "idle";
|
|
860
|
+
#overlay;
|
|
861
|
+
#frames = [];
|
|
862
|
+
#overlayLoaded = false;
|
|
863
|
+
#rendererReady = false;
|
|
864
|
+
#resolve;
|
|
865
|
+
#settled = false;
|
|
866
|
+
#readyTimer;
|
|
867
|
+
#windowCleanups = [];
|
|
868
|
+
#diagnosticStageStarts = /* @__PURE__ */ new Map();
|
|
869
|
+
constructor(options) {
|
|
870
|
+
this.#options = options;
|
|
871
|
+
this.#resourceLimits = resolveScreenshotResourceLimits(options.resourceLimits);
|
|
872
|
+
}
|
|
873
|
+
get state() {
|
|
874
|
+
return this.#state;
|
|
875
|
+
}
|
|
876
|
+
/** 允许主进程在宿主窗口销毁或业务主动终止时结束任意活动阶段。 */
|
|
877
|
+
cancel() {
|
|
878
|
+
if (this.#settled || this.#state === "idle") {
|
|
879
|
+
return false;
|
|
880
|
+
}
|
|
881
|
+
this.#settle({ status: "cancelled" });
|
|
882
|
+
return true;
|
|
883
|
+
}
|
|
884
|
+
run() {
|
|
885
|
+
if (this.#state !== "idle") {
|
|
886
|
+
return Promise.resolve({
|
|
887
|
+
status: "failed",
|
|
888
|
+
code: "INVALID_RESULT",
|
|
889
|
+
message: "A screenshot session can only be run once."
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
return new Promise((resolve2) => {
|
|
893
|
+
this.#resolve = resolve2;
|
|
894
|
+
void this.#start();
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
async #start() {
|
|
898
|
+
this.#state = "capturing";
|
|
899
|
+
this.#startDiagnosticStage("capture");
|
|
900
|
+
let targetDisplay;
|
|
901
|
+
try {
|
|
902
|
+
targetDisplay = this.#options.captureAdapter.resolveTargetDisplay?.(
|
|
903
|
+
this.#options.captureOptions
|
|
904
|
+
);
|
|
905
|
+
if (targetDisplay) {
|
|
906
|
+
this.#openOverlay(targetDisplay);
|
|
907
|
+
}
|
|
908
|
+
} catch {
|
|
909
|
+
}
|
|
910
|
+
let frames;
|
|
911
|
+
try {
|
|
912
|
+
frames = targetDisplay ? await this.#options.captureAdapter.capture(
|
|
913
|
+
this.#options.captureOptions,
|
|
914
|
+
targetDisplay
|
|
915
|
+
) : await this.#options.captureAdapter.capture(this.#options.captureOptions);
|
|
916
|
+
if (this.#settled) {
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
if (!frames[0]) {
|
|
920
|
+
throw new ScreenshotError(
|
|
921
|
+
"CAPTURE_FAILED",
|
|
922
|
+
"Screen capture returned no frames."
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
if (targetDisplay && !isSameDisplayGeometry(targetDisplay, frames[0].display)) {
|
|
926
|
+
throw new ScreenshotError(
|
|
927
|
+
"DISPLAY_NOT_FOUND",
|
|
928
|
+
"The target display changed while the screenshot was starting. Please retry."
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
for (const frame of frames) {
|
|
932
|
+
const violation = findCapturedFrameLimitViolation(frame, this.#resourceLimits);
|
|
933
|
+
if (violation) {
|
|
934
|
+
throw new ScreenshotError("RESOURCE_LIMIT_EXCEEDED", violation);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
this.#finishDiagnosticStage("capture", "complete", {
|
|
938
|
+
frameCount: frames.length,
|
|
939
|
+
capturePixels: frames.reduce(
|
|
940
|
+
(total, frame) => total + frame.pixelSize.width * frame.pixelSize.height,
|
|
941
|
+
0
|
|
942
|
+
)
|
|
943
|
+
});
|
|
944
|
+
} catch (error) {
|
|
945
|
+
const failure = toScreenshotFailure(error);
|
|
946
|
+
this.#finishDiagnosticStage("capture", "error", diagnosticError(error), failure);
|
|
947
|
+
this.#settle(failure);
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
this.#frames = frames;
|
|
951
|
+
this.#state = "opening-overlay";
|
|
952
|
+
if (!this.#overlay) {
|
|
953
|
+
this.#openOverlay(frames[0].display);
|
|
954
|
+
}
|
|
955
|
+
this.#prepareOverlayWhenReady();
|
|
956
|
+
}
|
|
957
|
+
#openOverlay(display) {
|
|
958
|
+
if (this.#settled || this.#overlay) {
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
this.#startDiagnosticStage("overlay-create");
|
|
962
|
+
try {
|
|
963
|
+
this.#overlay = this.#options.createOverlay(display);
|
|
964
|
+
this.#finishDiagnosticStage("overlay-create", "complete", {
|
|
965
|
+
displayId: display.id,
|
|
966
|
+
overlayWebContentsId: this.#overlay.webContentsId
|
|
967
|
+
});
|
|
968
|
+
} catch (error) {
|
|
969
|
+
const failure = toScreenshotFailure(error, "OVERLAY_LOAD_FAILED");
|
|
970
|
+
this.#finishDiagnosticStage(
|
|
971
|
+
"overlay-create",
|
|
972
|
+
"error",
|
|
973
|
+
diagnosticError(error),
|
|
974
|
+
failure
|
|
975
|
+
);
|
|
976
|
+
this.#settle(failure);
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
this.#registerListeners();
|
|
980
|
+
this.#startReadyTimeout();
|
|
981
|
+
this.#startDiagnosticStage("overlay-load");
|
|
982
|
+
this.#startDiagnosticStage("overlay-ready");
|
|
983
|
+
void this.#loadOverlay();
|
|
984
|
+
}
|
|
985
|
+
async #loadOverlay() {
|
|
986
|
+
try {
|
|
987
|
+
await this.#overlay?.load();
|
|
988
|
+
if (this.#settled) {
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
this.#overlayLoaded = true;
|
|
992
|
+
this.#finishDiagnosticStage("overlay-load", "complete");
|
|
993
|
+
this.#prepareOverlayWhenReady();
|
|
994
|
+
} catch (error) {
|
|
995
|
+
const failure = toScreenshotFailure(error, "OVERLAY_LOAD_FAILED");
|
|
996
|
+
this.#finishDiagnosticStage(
|
|
997
|
+
"overlay-load",
|
|
998
|
+
"error",
|
|
999
|
+
diagnosticError(error),
|
|
1000
|
+
failure
|
|
1001
|
+
);
|
|
1002
|
+
this.#settle(failure);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
#registerListeners() {
|
|
1006
|
+
this.#options.ipcMain.on(OVERLAY_CHANNELS.ready, this.#handleReady);
|
|
1007
|
+
this.#options.ipcMain.on(OVERLAY_CHANNELS.prepared, this.#handlePrepared);
|
|
1008
|
+
this.#options.ipcMain.on(OVERLAY_CHANNELS.confirm, this.#handleConfirm);
|
|
1009
|
+
this.#options.ipcMain.on(OVERLAY_CHANNELS.cancel, this.#handleCancel);
|
|
1010
|
+
this.#options.ipcMain.on(OVERLAY_CHANNELS.error, this.#handleError);
|
|
1011
|
+
if (this.#overlay) {
|
|
1012
|
+
const outputCleanup = this.#options.registerOutputHandler?.(
|
|
1013
|
+
this.#overlay.webContentsId,
|
|
1014
|
+
this.#options.jobId
|
|
1015
|
+
);
|
|
1016
|
+
if (outputCleanup) {
|
|
1017
|
+
this.#windowCleanups.push(outputCleanup);
|
|
1018
|
+
}
|
|
1019
|
+
this.#windowCleanups.push(
|
|
1020
|
+
this.#overlay.onClosed(() => {
|
|
1021
|
+
if (!this.#settled) {
|
|
1022
|
+
this.#settle({ status: "cancelled" });
|
|
1023
|
+
}
|
|
1024
|
+
}),
|
|
1025
|
+
this.#overlay.onRendererGone(() => {
|
|
1026
|
+
if (!this.#settled) {
|
|
1027
|
+
this.#settle({
|
|
1028
|
+
status: "failed",
|
|
1029
|
+
code: "OVERLAY_LOAD_FAILED",
|
|
1030
|
+
message: "The screenshot overlay renderer exited unexpectedly."
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
})
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
#handleReady = (event, payload) => {
|
|
1038
|
+
if (!this.#isExpectedSender(event)) {
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
if (!isReadyPayload(payload)) {
|
|
1042
|
+
this.#settleInvalidMessage("Invalid overlay ready message.");
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
if (this.#state !== "capturing" && this.#state !== "opening-overlay" || !this.#overlay || this.#rendererReady) {
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
this.#clearReadyTimeout();
|
|
1049
|
+
this.#finishDiagnosticStage("overlay-ready", "complete");
|
|
1050
|
+
this.#rendererReady = true;
|
|
1051
|
+
this.#prepareOverlayWhenReady();
|
|
1052
|
+
};
|
|
1053
|
+
#prepareOverlayWhenReady() {
|
|
1054
|
+
if (this.#settled || this.#state !== "opening-overlay" || !this.#overlay || !this.#overlayLoaded || !this.#rendererReady || !this.#frames[0]) {
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
this.#state = "preparing-overlay";
|
|
1058
|
+
this.#startDiagnosticStage("overlay-prepare");
|
|
1059
|
+
this.#overlay.prime();
|
|
1060
|
+
this.#overlay.sendInitialize({
|
|
1061
|
+
protocolVersion: SCREENSHOT_PROTOCOL_VERSION,
|
|
1062
|
+
jobId: this.#options.jobId,
|
|
1063
|
+
options: this.#options.captureOptions,
|
|
1064
|
+
frames: this.#frames
|
|
1065
|
+
});
|
|
1066
|
+
this.#frames = [];
|
|
1067
|
+
this.#startReadyTimeout(
|
|
1068
|
+
"The screenshot overlay did not prepare its captured frame in time."
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
#handlePrepared = (event, payload) => {
|
|
1072
|
+
if (!this.#isExpectedSender(event)) {
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
if (!isPreparedPayload(payload, this.#options.jobId)) {
|
|
1076
|
+
this.#settleInvalidMessage("Invalid overlay prepared message.");
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
if (this.#state !== "preparing-overlay" || !this.#overlay) {
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
this.#clearReadyTimeout();
|
|
1083
|
+
this.#finishDiagnosticStage("overlay-prepare", "complete");
|
|
1084
|
+
this.#state = "editing";
|
|
1085
|
+
this.#overlay.reveal();
|
|
1086
|
+
};
|
|
1087
|
+
#handleConfirm = (event, payload) => {
|
|
1088
|
+
if (!this.#isExpectedSender(event) || this.#state !== "editing") {
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
if (!isCompletePayload(
|
|
1092
|
+
payload,
|
|
1093
|
+
this.#options.jobId,
|
|
1094
|
+
this.#resourceLimits.maxOutputBytes
|
|
1095
|
+
)) {
|
|
1096
|
+
this.#settleInvalidMessage("Invalid screenshot completion message.");
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
this.#state = "exporting";
|
|
1100
|
+
this.#settle(payload.result);
|
|
1101
|
+
};
|
|
1102
|
+
#handleCancel = (event, payload) => {
|
|
1103
|
+
if (!this.#isExpectedSender(event) || this.#state !== "preparing-overlay" && this.#state !== "editing") {
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
if (!isCancelPayload(payload, this.#options.jobId)) {
|
|
1107
|
+
this.#settleInvalidMessage("Invalid screenshot cancellation message.");
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
this.#settle({ status: "cancelled" });
|
|
1111
|
+
};
|
|
1112
|
+
#handleError = (event, payload) => {
|
|
1113
|
+
if (!this.#isExpectedSender(event) || this.#state !== "preparing-overlay" && this.#state !== "editing") {
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
if (!isErrorPayload(payload, this.#options.jobId)) {
|
|
1117
|
+
this.#settleInvalidMessage("Invalid screenshot error message.");
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
this.#settle({
|
|
1121
|
+
status: "failed",
|
|
1122
|
+
code: payload.code,
|
|
1123
|
+
message: payload.message
|
|
1124
|
+
});
|
|
1125
|
+
};
|
|
1126
|
+
#isExpectedSender(event) {
|
|
1127
|
+
return this.#overlay !== void 0 && event.sender.id === this.#overlay.webContentsId;
|
|
1128
|
+
}
|
|
1129
|
+
#settleInvalidMessage(message) {
|
|
1130
|
+
this.#settle({
|
|
1131
|
+
status: "failed",
|
|
1132
|
+
code: "INVALID_RESULT",
|
|
1133
|
+
message
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
#startReadyTimeout(message = "The screenshot overlay did not become ready in time.") {
|
|
1137
|
+
this.#clearReadyTimeout();
|
|
1138
|
+
const timeoutMs = this.#options.overlayReadyTimeoutMs ?? 1e4;
|
|
1139
|
+
this.#readyTimer = setTimeout(() => {
|
|
1140
|
+
this.#settle({
|
|
1141
|
+
status: "failed",
|
|
1142
|
+
code: "OVERLAY_LOAD_FAILED",
|
|
1143
|
+
message
|
|
1144
|
+
});
|
|
1145
|
+
}, timeoutMs);
|
|
1146
|
+
this.#readyTimer.unref?.();
|
|
1147
|
+
}
|
|
1148
|
+
#clearReadyTimeout() {
|
|
1149
|
+
if (this.#readyTimer) {
|
|
1150
|
+
clearTimeout(this.#readyTimer);
|
|
1151
|
+
this.#readyTimer = void 0;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
#settle(result) {
|
|
1155
|
+
if (this.#settled) {
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
this.#settled = true;
|
|
1159
|
+
const phase = result.status === "cancelled" ? "cancel" : result.status === "failed" ? "error" : "complete";
|
|
1160
|
+
for (const stage of [...this.#diagnosticStageStarts.keys()]) {
|
|
1161
|
+
this.#finishDiagnosticStage(stage, phase, void 0, result);
|
|
1162
|
+
}
|
|
1163
|
+
this.#state = result.status === "completed" ? "completed" : result.status === "cancelled" ? "cancelled" : "failed";
|
|
1164
|
+
this.#clearReadyTimeout();
|
|
1165
|
+
this.#removeListeners();
|
|
1166
|
+
this.#frames = [];
|
|
1167
|
+
if (result.status === "completed" && result.output.action === "copy" && this.#overlay?.showCopyFeedback) {
|
|
1168
|
+
this.#overlay.showCopyFeedback(3e3, this.#options.captureOptions);
|
|
1169
|
+
} else {
|
|
1170
|
+
this.#overlay?.destroy();
|
|
1171
|
+
}
|
|
1172
|
+
this.#options.onSettled?.();
|
|
1173
|
+
this.#resolve?.(result);
|
|
1174
|
+
this.#resolve = void 0;
|
|
1175
|
+
}
|
|
1176
|
+
#startDiagnosticStage(stage, context) {
|
|
1177
|
+
const timestamp = Date.now();
|
|
1178
|
+
this.#diagnosticStageStarts.set(stage, timestamp);
|
|
1179
|
+
emitScreenshotDiagnostic(this.#options.onDiagnostic, {
|
|
1180
|
+
jobId: this.#options.jobId,
|
|
1181
|
+
stage,
|
|
1182
|
+
phase: "start",
|
|
1183
|
+
timestamp,
|
|
1184
|
+
...context ? { context } : {}
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
#finishDiagnosticStage(stage, phase, context, result) {
|
|
1188
|
+
const startedAt = this.#diagnosticStageStarts.get(stage);
|
|
1189
|
+
if (startedAt === void 0) {
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
this.#diagnosticStageStarts.delete(stage);
|
|
1193
|
+
const timestamp = Date.now();
|
|
1194
|
+
emitScreenshotDiagnostic(this.#options.onDiagnostic, {
|
|
1195
|
+
jobId: this.#options.jobId,
|
|
1196
|
+
stage,
|
|
1197
|
+
phase,
|
|
1198
|
+
timestamp,
|
|
1199
|
+
durationMs: Math.max(0, timestamp - startedAt),
|
|
1200
|
+
...result?.status === "failed" ? { code: result.code, message: result.message } : {},
|
|
1201
|
+
...context ? { context } : {}
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
#removeListeners() {
|
|
1205
|
+
this.#options.ipcMain.removeListener(OVERLAY_CHANNELS.ready, this.#handleReady);
|
|
1206
|
+
this.#options.ipcMain.removeListener(
|
|
1207
|
+
OVERLAY_CHANNELS.prepared,
|
|
1208
|
+
this.#handlePrepared
|
|
1209
|
+
);
|
|
1210
|
+
this.#options.ipcMain.removeListener(OVERLAY_CHANNELS.confirm, this.#handleConfirm);
|
|
1211
|
+
this.#options.ipcMain.removeListener(OVERLAY_CHANNELS.cancel, this.#handleCancel);
|
|
1212
|
+
this.#options.ipcMain.removeListener(OVERLAY_CHANNELS.error, this.#handleError);
|
|
1213
|
+
for (const cleanup of this.#windowCleanups.splice(0)) {
|
|
1214
|
+
cleanup();
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
};
|
|
1218
|
+
function diagnosticError(error) {
|
|
1219
|
+
const context = {
|
|
1220
|
+
errorName: error instanceof Error ? error.name : typeof error
|
|
1221
|
+
};
|
|
1222
|
+
if (error instanceof PackagedResourceError) {
|
|
1223
|
+
context.missingResources = error.missingResources.map(
|
|
1224
|
+
(resource) => `${resource.label}: ${resource.path}`
|
|
1225
|
+
);
|
|
1226
|
+
}
|
|
1227
|
+
return context;
|
|
1228
|
+
}
|
|
1229
|
+
function isSameDisplayGeometry(expected, actual) {
|
|
1230
|
+
return expected.id === actual.id && expected.scaleFactor === actual.scaleFactor && expected.bounds.x === actual.bounds.x && expected.bounds.y === actual.bounds.y && expected.bounds.width === actual.bounds.width && expected.bounds.height === actual.bounds.height;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// src/electron/main/screenshot-manager.ts
|
|
1234
|
+
var DEFAULT_MAX_QUEUED_CAPTURES = 8;
|
|
1235
|
+
function createDefaultRunner(managerOptions) {
|
|
1236
|
+
const ipcMain = managerOptions.ipcMain ?? electronIpcMain;
|
|
1237
|
+
const resourceLimits = resolveScreenshotResourceLimits(managerOptions.resourceLimits);
|
|
1238
|
+
const captureAdapter = managerOptions.captureAdapter ?? new ElectronCaptureAdapter({ resourceLimits });
|
|
1239
|
+
const outputAdapter = managerOptions.outputAdapter ?? new ElectronOutputAdapter();
|
|
1240
|
+
const outputRouter = getScreenshotOutputRouter(ipcMain);
|
|
1241
|
+
const createOverlayWindow = managerOptions.createOverlay ?? ((display) => new OverlayWindow({ ...managerOptions.overlayOptions, display }));
|
|
1242
|
+
let previousOverlay;
|
|
1243
|
+
return (jobId, captureOptions, context) => {
|
|
1244
|
+
previousOverlay?.destroy();
|
|
1245
|
+
previousOverlay = void 0;
|
|
1246
|
+
const reportDiagnostic = (event) => {
|
|
1247
|
+
emitScreenshotDiagnostic(managerOptions.onDiagnostic, {
|
|
1248
|
+
...event,
|
|
1249
|
+
...context.senderWebContentsId === void 0 ? {} : { senderWebContentsId: context.senderWebContentsId }
|
|
1250
|
+
});
|
|
1251
|
+
};
|
|
1252
|
+
const session = new ScreenshotSession({
|
|
1253
|
+
jobId,
|
|
1254
|
+
captureOptions,
|
|
1255
|
+
captureAdapter,
|
|
1256
|
+
ipcMain,
|
|
1257
|
+
createOverlay: (display) => {
|
|
1258
|
+
const overlay = createOverlayWindow(display);
|
|
1259
|
+
previousOverlay = overlay;
|
|
1260
|
+
return overlay;
|
|
1261
|
+
},
|
|
1262
|
+
...managerOptions.overlayReadyTimeoutMs === void 0 ? {} : { overlayReadyTimeoutMs: managerOptions.overlayReadyTimeoutMs },
|
|
1263
|
+
resourceLimits,
|
|
1264
|
+
onDiagnostic: reportDiagnostic,
|
|
1265
|
+
registerOutputHandler: (senderWebContentsId, activeJobId) => outputRouter.register(
|
|
1266
|
+
senderWebContentsId,
|
|
1267
|
+
activeJobId,
|
|
1268
|
+
async (payload, outputContext) => {
|
|
1269
|
+
const startedAt = Date.now();
|
|
1270
|
+
reportDiagnostic({
|
|
1271
|
+
jobId: activeJobId,
|
|
1272
|
+
stage: "output",
|
|
1273
|
+
phase: "start",
|
|
1274
|
+
timestamp: startedAt,
|
|
1275
|
+
context: {
|
|
1276
|
+
action: payload.action,
|
|
1277
|
+
outputBytes: payload.result.data.byteLength
|
|
1278
|
+
}
|
|
1279
|
+
});
|
|
1280
|
+
let response;
|
|
1281
|
+
try {
|
|
1282
|
+
response = payload.result.data.byteLength > resourceLimits.maxOutputBytes ? {
|
|
1283
|
+
status: "failed",
|
|
1284
|
+
code: "RESOURCE_LIMIT_EXCEEDED",
|
|
1285
|
+
message: `Screenshot output exceeds the ${resourceLimits.maxOutputBytes} byte limit.`
|
|
1286
|
+
} : await outputAdapter.execute(payload, outputContext);
|
|
1287
|
+
} catch (error) {
|
|
1288
|
+
const timestamp2 = Date.now();
|
|
1289
|
+
reportDiagnostic({
|
|
1290
|
+
jobId: activeJobId,
|
|
1291
|
+
stage: "output",
|
|
1292
|
+
phase: "error",
|
|
1293
|
+
timestamp: timestamp2,
|
|
1294
|
+
durationMs: Math.max(0, timestamp2 - startedAt),
|
|
1295
|
+
code: "EXPORT_FAILED",
|
|
1296
|
+
message: error instanceof Error ? error.message : "Screenshot output failed.",
|
|
1297
|
+
context: { action: payload.action }
|
|
1298
|
+
});
|
|
1299
|
+
throw error;
|
|
1300
|
+
}
|
|
1301
|
+
const timestamp = Date.now();
|
|
1302
|
+
reportDiagnostic({
|
|
1303
|
+
jobId: activeJobId,
|
|
1304
|
+
stage: "output",
|
|
1305
|
+
phase: response.status === "failed" ? "error" : response.status === "cancelled" ? "cancel" : "complete",
|
|
1306
|
+
timestamp,
|
|
1307
|
+
durationMs: Math.max(0, timestamp - startedAt),
|
|
1308
|
+
...response.status === "failed" ? { code: response.code, message: response.message } : {},
|
|
1309
|
+
context: { action: payload.action }
|
|
1310
|
+
});
|
|
1311
|
+
return response;
|
|
1312
|
+
}
|
|
1313
|
+
),
|
|
1314
|
+
onSettled: () => {
|
|
1315
|
+
if (context.senderWebContentsId === void 0) {
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
const sender = webContents2.fromId(context.senderWebContentsId);
|
|
1319
|
+
if (sender && !sender.isDestroyed()) {
|
|
1320
|
+
sender.focus();
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
});
|
|
1324
|
+
return {
|
|
1325
|
+
result: session.run(),
|
|
1326
|
+
cancel: () => session.cancel()
|
|
1327
|
+
};
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
var ScreenshotManager = class {
|
|
1331
|
+
#runner;
|
|
1332
|
+
#busyPolicy;
|
|
1333
|
+
#maxQueuedCaptures;
|
|
1334
|
+
#queue = [];
|
|
1335
|
+
#onDiagnostic;
|
|
1336
|
+
#activeJobId;
|
|
1337
|
+
#activeSenderWebContentsId;
|
|
1338
|
+
#cancelActive;
|
|
1339
|
+
constructor(options = {}) {
|
|
1340
|
+
if (options.busyPolicy !== void 0 && !["reject", "queue"].includes(options.busyPolicy)) {
|
|
1341
|
+
throw new TypeError("busyPolicy must be either reject or queue.");
|
|
1342
|
+
}
|
|
1343
|
+
this.#busyPolicy = options.busyPolicy ?? "reject";
|
|
1344
|
+
this.#maxQueuedCaptures = options.maxQueuedCaptures ?? DEFAULT_MAX_QUEUED_CAPTURES;
|
|
1345
|
+
if (!Number.isSafeInteger(this.#maxQueuedCaptures) || this.#maxQueuedCaptures < 1 || this.#maxQueuedCaptures > 100) {
|
|
1346
|
+
throw new TypeError("maxQueuedCaptures must be an integer between 1 and 100.");
|
|
1347
|
+
}
|
|
1348
|
+
this.#runner = options.runner ?? createDefaultRunner(options);
|
|
1349
|
+
this.#onDiagnostic = options.onDiagnostic;
|
|
1350
|
+
}
|
|
1351
|
+
get activeJobId() {
|
|
1352
|
+
return this.#activeJobId;
|
|
1353
|
+
}
|
|
1354
|
+
get queuedCaptureCount() {
|
|
1355
|
+
return this.#queue.length;
|
|
1356
|
+
}
|
|
1357
|
+
/** 不传 sender ID 时供主进程直接取消;传入时只允许取消该页面创建的任务。 */
|
|
1358
|
+
cancel(senderWebContentsId) {
|
|
1359
|
+
let cancelled = false;
|
|
1360
|
+
if (this.#activeJobId && (senderWebContentsId === void 0 || this.#activeSenderWebContentsId === senderWebContentsId)) {
|
|
1361
|
+
cancelled = this.#cancelActive?.() ?? false;
|
|
1362
|
+
}
|
|
1363
|
+
if (senderWebContentsId !== void 0) {
|
|
1364
|
+
for (let index = this.#queue.length - 1; index >= 0; index -= 1) {
|
|
1365
|
+
const queued = this.#queue[index];
|
|
1366
|
+
if (queued?.context.senderWebContentsId !== senderWebContentsId) {
|
|
1367
|
+
continue;
|
|
1368
|
+
}
|
|
1369
|
+
this.#queue.splice(index, 1);
|
|
1370
|
+
this.#finishDiagnosticStage(
|
|
1371
|
+
queued.jobId,
|
|
1372
|
+
"queue",
|
|
1373
|
+
"cancel",
|
|
1374
|
+
queued.queuedAt,
|
|
1375
|
+
queued.context,
|
|
1376
|
+
void 0,
|
|
1377
|
+
{ queueDepth: this.#queue.length }
|
|
1378
|
+
);
|
|
1379
|
+
queued.resolve({ status: "cancelled" });
|
|
1380
|
+
cancelled = true;
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
return cancelled;
|
|
1384
|
+
}
|
|
1385
|
+
capture(options = {}, context = {}) {
|
|
1386
|
+
if (this.#activeJobId) {
|
|
1387
|
+
return this.#handleBusyCapture(options, context);
|
|
1388
|
+
}
|
|
1389
|
+
return this.#executeCapture(options, context);
|
|
1390
|
+
}
|
|
1391
|
+
#handleBusyCapture(options, context) {
|
|
1392
|
+
const senderWebContentsId = context.senderWebContentsId;
|
|
1393
|
+
const sameSenderAlreadyPending = senderWebContentsId !== void 0 && (this.#activeSenderWebContentsId === senderWebContentsId || this.#queue.some(
|
|
1394
|
+
(queued) => queued.context.senderWebContentsId === senderWebContentsId
|
|
1395
|
+
));
|
|
1396
|
+
if (this.#busyPolicy === "reject" || sameSenderAlreadyPending || this.#queue.length >= this.#maxQueuedCaptures) {
|
|
1397
|
+
const result = {
|
|
1398
|
+
status: "failed",
|
|
1399
|
+
code: "CAPTURE_BUSY",
|
|
1400
|
+
message: sameSenderAlreadyPending ? "This window already has a screenshot task pending." : "A screenshot task is already running."
|
|
1401
|
+
};
|
|
1402
|
+
const timestamp = Date.now();
|
|
1403
|
+
this.#emitDiagnostic({
|
|
1404
|
+
jobId: randomUUID(),
|
|
1405
|
+
stage: "queue",
|
|
1406
|
+
phase: "error",
|
|
1407
|
+
timestamp,
|
|
1408
|
+
durationMs: 0,
|
|
1409
|
+
...senderWebContentsId === void 0 ? {} : { senderWebContentsId },
|
|
1410
|
+
code: result.code,
|
|
1411
|
+
message: result.message,
|
|
1412
|
+
context: {
|
|
1413
|
+
queueDepth: this.#queue.length,
|
|
1414
|
+
reason: sameSenderAlreadyPending ? "sender-mutex" : this.#busyPolicy === "reject" ? "policy" : "queue-capacity"
|
|
1415
|
+
}
|
|
1416
|
+
});
|
|
1417
|
+
return Promise.resolve(result);
|
|
1418
|
+
}
|
|
1419
|
+
return new Promise((resolve2) => {
|
|
1420
|
+
const jobId = randomUUID();
|
|
1421
|
+
const queuedAt = Date.now();
|
|
1422
|
+
this.#queue.push({ jobId, queuedAt, options, context, resolve: resolve2 });
|
|
1423
|
+
this.#emitDiagnostic({
|
|
1424
|
+
jobId,
|
|
1425
|
+
stage: "queue",
|
|
1426
|
+
phase: "start",
|
|
1427
|
+
timestamp: queuedAt,
|
|
1428
|
+
...senderWebContentsId === void 0 ? {} : { senderWebContentsId },
|
|
1429
|
+
context: { queueDepth: this.#queue.length }
|
|
1430
|
+
});
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
async #executeCapture(options, context, jobId = randomUUID()) {
|
|
1434
|
+
const startedAt = Date.now();
|
|
1435
|
+
this.#activeJobId = jobId;
|
|
1436
|
+
this.#activeSenderWebContentsId = context.senderWebContentsId;
|
|
1437
|
+
this.#emitDiagnostic({
|
|
1438
|
+
jobId,
|
|
1439
|
+
stage: "session",
|
|
1440
|
+
phase: "start",
|
|
1441
|
+
timestamp: startedAt,
|
|
1442
|
+
...context.senderWebContentsId === void 0 ? {} : { senderWebContentsId: context.senderWebContentsId }
|
|
1443
|
+
});
|
|
1444
|
+
let result;
|
|
1445
|
+
try {
|
|
1446
|
+
const execution = this.#runner(jobId, options, context);
|
|
1447
|
+
if (isScreenshotExecution(execution)) {
|
|
1448
|
+
this.#cancelActive = execution.cancel;
|
|
1449
|
+
result = await execution.result;
|
|
1450
|
+
} else {
|
|
1451
|
+
result = await execution;
|
|
1452
|
+
}
|
|
1453
|
+
} catch (error) {
|
|
1454
|
+
result = toScreenshotFailure(error);
|
|
1455
|
+
}
|
|
1456
|
+
this.#finishDiagnosticStage(
|
|
1457
|
+
jobId,
|
|
1458
|
+
"session",
|
|
1459
|
+
diagnosticPhaseForResult(result),
|
|
1460
|
+
startedAt,
|
|
1461
|
+
context,
|
|
1462
|
+
result,
|
|
1463
|
+
{ status: result.status }
|
|
1464
|
+
);
|
|
1465
|
+
if (this.#activeJobId === jobId) {
|
|
1466
|
+
this.#activeJobId = void 0;
|
|
1467
|
+
this.#activeSenderWebContentsId = void 0;
|
|
1468
|
+
this.#cancelActive = void 0;
|
|
1469
|
+
this.#startNextQueuedCapture();
|
|
1470
|
+
}
|
|
1471
|
+
return result;
|
|
1472
|
+
}
|
|
1473
|
+
#startNextQueuedCapture() {
|
|
1474
|
+
const next = this.#queue.shift();
|
|
1475
|
+
if (!next) {
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
this.#finishDiagnosticStage(
|
|
1479
|
+
next.jobId,
|
|
1480
|
+
"queue",
|
|
1481
|
+
"complete",
|
|
1482
|
+
next.queuedAt,
|
|
1483
|
+
next.context,
|
|
1484
|
+
void 0,
|
|
1485
|
+
{ queueDepth: this.#queue.length }
|
|
1486
|
+
);
|
|
1487
|
+
void this.#executeCapture(next.options, next.context, next.jobId).then(
|
|
1488
|
+
next.resolve
|
|
1489
|
+
);
|
|
1490
|
+
}
|
|
1491
|
+
#finishDiagnosticStage(jobId, stage, phase, startedAt, context, result, diagnosticContext) {
|
|
1492
|
+
const timestamp = Date.now();
|
|
1493
|
+
this.#emitDiagnostic({
|
|
1494
|
+
jobId,
|
|
1495
|
+
stage,
|
|
1496
|
+
phase,
|
|
1497
|
+
timestamp,
|
|
1498
|
+
durationMs: Math.max(0, timestamp - startedAt),
|
|
1499
|
+
...context.senderWebContentsId === void 0 ? {} : { senderWebContentsId: context.senderWebContentsId },
|
|
1500
|
+
...result?.status === "failed" ? { code: result.code, message: result.message } : {},
|
|
1501
|
+
...diagnosticContext ? { context: diagnosticContext } : {}
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
#emitDiagnostic(event) {
|
|
1505
|
+
emitScreenshotDiagnostic(this.#onDiagnostic, event);
|
|
1506
|
+
}
|
|
1507
|
+
};
|
|
1508
|
+
function isScreenshotExecution(value) {
|
|
1509
|
+
return "result" in value && typeof value.cancel === "function";
|
|
1510
|
+
}
|
|
1511
|
+
function diagnosticPhaseForResult(result) {
|
|
1512
|
+
return result.status === "failed" ? "error" : result.status === "cancelled" ? "cancel" : "complete";
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
// src/electron/main/register-host-ipc.ts
|
|
1516
|
+
function setupElectronSnapora(options) {
|
|
1517
|
+
const { managerOptions, ...ipcOptions } = options;
|
|
1518
|
+
const manager = new ScreenshotManager(managerOptions);
|
|
1519
|
+
return {
|
|
1520
|
+
manager,
|
|
1521
|
+
preloadPath: resolveHostPreloadPath(),
|
|
1522
|
+
unregister: registerScreenshotIpc({ ...ipcOptions, manager })
|
|
1523
|
+
};
|
|
1524
|
+
}
|
|
1525
|
+
function registerScreenshotIpc(options) {
|
|
1526
|
+
const channel = options.channel ?? DEFAULT_HOST_CAPTURE_CHANNEL;
|
|
1527
|
+
const cancelChannel = options.cancelChannel ?? (options.channel ? `${options.channel}:cancel` : DEFAULT_HOST_CANCEL_CHANNEL);
|
|
1528
|
+
options.ipcMain.handle(channel, async (event, captureOptions) => {
|
|
1529
|
+
if (!isAuthorizedSender(event, options.validateSender)) {
|
|
1530
|
+
return invalidRequest("The screenshot request sender is not authorized.");
|
|
1531
|
+
}
|
|
1532
|
+
const parsed = parseScreenshotOptions(captureOptions);
|
|
1533
|
+
if (!parsed.success) {
|
|
1534
|
+
return invalidRequest(parsed.message);
|
|
1535
|
+
}
|
|
1536
|
+
const senderWebContentsId = event.sender.id;
|
|
1537
|
+
const cancelWhenDestroyed = () => {
|
|
1538
|
+
options.manager.cancel(senderWebContentsId);
|
|
1539
|
+
};
|
|
1540
|
+
event.sender.once("destroyed", cancelWhenDestroyed);
|
|
1541
|
+
try {
|
|
1542
|
+
return await options.manager.capture(parsed.value, { senderWebContentsId });
|
|
1543
|
+
} finally {
|
|
1544
|
+
event.sender.removeListener("destroyed", cancelWhenDestroyed);
|
|
1545
|
+
}
|
|
1546
|
+
});
|
|
1547
|
+
options.ipcMain.handle(cancelChannel, async (event) => {
|
|
1548
|
+
if (!isAuthorizedSender(event, options.validateSender)) {
|
|
1549
|
+
return false;
|
|
1550
|
+
}
|
|
1551
|
+
return options.manager.cancel(event.sender.id);
|
|
1552
|
+
});
|
|
1553
|
+
return () => {
|
|
1554
|
+
options.ipcMain.removeHandler(channel);
|
|
1555
|
+
options.ipcMain.removeHandler(cancelChannel);
|
|
1556
|
+
};
|
|
1557
|
+
}
|
|
1558
|
+
function isAuthorizedSender(event, validateSender) {
|
|
1559
|
+
if (!event.senderFrame || event.senderFrame !== event.sender.mainFrame) {
|
|
1560
|
+
return false;
|
|
1561
|
+
}
|
|
1562
|
+
if (validateSender) {
|
|
1563
|
+
try {
|
|
1564
|
+
return validateSender(event);
|
|
1565
|
+
} catch {
|
|
1566
|
+
return false;
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
try {
|
|
1570
|
+
return new URL(event.senderFrame.url).protocol === "file:";
|
|
1571
|
+
} catch {
|
|
1572
|
+
return false;
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
function invalidRequest(message) {
|
|
1576
|
+
return { status: "failed", code: "INVALID_REQUEST", message };
|
|
1577
|
+
}
|
|
1578
|
+
export {
|
|
1579
|
+
DEFAULT_HOST_CANCEL_CHANNEL,
|
|
1580
|
+
DEFAULT_HOST_CAPTURE_CHANNEL,
|
|
1581
|
+
DEFAULT_SCREENSHOT_RESOURCE_LIMITS,
|
|
1582
|
+
ElectronCaptureAdapter,
|
|
1583
|
+
ElectronOutputAdapter,
|
|
1584
|
+
HARD_SCREENSHOT_RESOURCE_LIMITS,
|
|
1585
|
+
OVERLAY_CHANNELS,
|
|
1586
|
+
OverlayWindow,
|
|
1587
|
+
PackagedResourceError,
|
|
1588
|
+
SCREENSHOT_PROTOCOL_VERSION,
|
|
1589
|
+
ScreenshotError,
|
|
1590
|
+
ScreenshotManager,
|
|
1591
|
+
ScreenshotSession,
|
|
1592
|
+
assertOverlayResources,
|
|
1593
|
+
registerScreenshotIpc,
|
|
1594
|
+
resolveHostPreloadPath,
|
|
1595
|
+
resolveOverlayResources,
|
|
1596
|
+
resolveScreenshotResourceLimits,
|
|
1597
|
+
setupElectronSnapora
|
|
1598
|
+
};
|
|
1599
|
+
//# sourceMappingURL=index.mjs.map
|