electron-verify-mcp 0.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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +155 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +172 -0
  5. package/dist/core/config.d.ts +74 -0
  6. package/dist/core/config.js +55 -0
  7. package/dist/core/index.d.ts +16 -0
  8. package/dist/core/index.js +15 -0
  9. package/dist/core/interact.d.ts +40 -0
  10. package/dist/core/interact.js +98 -0
  11. package/dist/core/launch.d.ts +29 -0
  12. package/dist/core/launch.js +98 -0
  13. package/dist/core/readiness.d.ts +23 -0
  14. package/dist/core/readiness.js +109 -0
  15. package/dist/core/screenshot.d.ts +30 -0
  16. package/dist/core/screenshot.js +176 -0
  17. package/dist/core/session.d.ts +54 -0
  18. package/dist/core/session.js +148 -0
  19. package/dist/core/signals/blankScreen.d.ts +30 -0
  20. package/dist/core/signals/blankScreen.js +106 -0
  21. package/dist/core/signals/collect.d.ts +21 -0
  22. package/dist/core/signals/collect.js +38 -0
  23. package/dist/core/signals/console.d.ts +62 -0
  24. package/dist/core/signals/console.js +335 -0
  25. package/dist/core/signals/layout.d.ts +14 -0
  26. package/dist/core/signals/layout.js +32 -0
  27. package/dist/core/signals/mainProcess.d.ts +17 -0
  28. package/dist/core/signals/mainProcess.js +61 -0
  29. package/dist/core/signals/resources.d.ts +24 -0
  30. package/dist/core/signals/resources.js +91 -0
  31. package/dist/core/signals/staleBuild.d.ts +18 -0
  32. package/dist/core/signals/staleBuild.js +109 -0
  33. package/dist/core/types.d.ts +80 -0
  34. package/dist/core/types.js +5 -0
  35. package/dist/core/verdict.d.ts +23 -0
  36. package/dist/core/verdict.js +87 -0
  37. package/dist/core/verify.d.ts +52 -0
  38. package/dist/core/verify.js +182 -0
  39. package/dist/core/windowCapture.d.ts +27 -0
  40. package/dist/core/windowCapture.js +236 -0
  41. package/dist/core/windows.d.ts +29 -0
  42. package/dist/core/windows.js +94 -0
  43. package/dist/mcp.d.ts +2 -0
  44. package/dist/mcp.js +578 -0
  45. package/package.json +74 -0
@@ -0,0 +1,182 @@
1
+ import { PNG } from "pngjs";
2
+ import { waitUntilReady } from "./readiness.js";
3
+ import { annotateHoles, captureRawScreenshot, downscaleIfNeeded, } from "./screenshot.js";
4
+ import { captureOsWindow, captureWindow } from "./windowCapture.js";
5
+ import { collectSignals } from "./signals/collect.js";
6
+ import { computeVerdict } from "./verdict.js";
7
+ import { detectHoles, selectTargetWindow } from "./windows.js";
8
+ /**
9
+ * Orchestrate one verification pass:
10
+ * select window → (optional freshLoad) → readiness → drain error buffer →
11
+ * capture content (for signals) + optional window capture → collect → verdict.
12
+ */
13
+ export async function verify(session, opts = {}) {
14
+ const selected = await selectTargetWindow(session.app, {
15
+ windowUrlMatch: opts.windowUrlMatch ?? session.windowUrlMatch,
16
+ });
17
+ session.setPage(selected.page);
18
+ const page = selected.page;
19
+ const pageUrl = page.url();
20
+ // freshLoad: explicit wins; else default TRUE when no interaction since last verify.
21
+ const freshLoad = opts.freshLoad !== undefined
22
+ ? opts.freshLoad
23
+ : !session.interactionSinceVerify;
24
+ await session.consoleCollector.installErrorBuffer(page);
25
+ if (freshLoad) {
26
+ try {
27
+ await page.reload({
28
+ waitUntil: "load",
29
+ timeout: opts.readinessTimeoutMs ?? 15_000,
30
+ });
31
+ }
32
+ catch {
33
+ // Some shells reject reload or hang past load; readiness still runs.
34
+ }
35
+ }
36
+ const readiness = await waitUntilReady(page, {
37
+ timeoutMs: opts.readinessTimeoutMs,
38
+ changeThreshold: opts.changeThreshold,
39
+ });
40
+ await session.consoleCollector.drainBufferedErrors(page);
41
+ // BrowserView bounds for the SELECTED window (not always getAllWindows()[0]).
42
+ const dipHoles = await detectHoles(session.app, pageUrl);
43
+ const dpr = await readDevicePixelRatio(page);
44
+ const holes = scaleHolesToPhysical(dipHoles, dpr);
45
+ // Signal analysis ALWAYS uses content capture.
46
+ const rawPng = await captureRawScreenshot(page);
47
+ const rawDecoded = PNG.sync.read(rawPng);
48
+ const annotated = holes.length > 0 ? annotateHoles(rawDecoded, holes) : rawDecoded;
49
+ let contentPng = PNG.sync.write(annotated);
50
+ const maxW = opts.maxScreenshotWidth ?? 1280;
51
+ contentPng = downscaleIfNeeded(contentPng, maxW);
52
+ const signals = await collectSignals(session, {
53
+ readiness,
54
+ png: rawPng,
55
+ holes,
56
+ criticalSelectors: opts.criticalSelectors ?? session.criticalSelectors,
57
+ srcGlobs: opts.srcGlobs ?? session.srcGlobs,
58
+ page,
59
+ blankCoverage: opts.blankCoverage,
60
+ });
61
+ // Advance delta windows so the next verify only sees subsequent errors/failures.
62
+ session.consoleCollector.markVerifyStart();
63
+ session.resourcesCollector.markVerifyStart();
64
+ session.clearInteractionFlag();
65
+ const strictConsole = opts.strictConsole ?? session.strictConsole;
66
+ const result = computeVerdict(signals, {
67
+ expect: opts.expect,
68
+ strictConsole,
69
+ });
70
+ // Human-facing capture mode (default: window).
71
+ const want = opts.captureMode ?? "window";
72
+ const packed = await packHumanScreenshots({
73
+ session,
74
+ pageUrl,
75
+ contentPng,
76
+ holes,
77
+ want,
78
+ maxW,
79
+ });
80
+ return {
81
+ ...result,
82
+ screenshot: packed.primary,
83
+ windowScreenshot: packed.windowPng,
84
+ contentScreenshot: packed.contentPng,
85
+ captureMode: packed.mode,
86
+ captureNote: packed.note,
87
+ windowStrategy: selected.strategy,
88
+ windowWarning: selected.warning,
89
+ holes: (want === "window" && packed.mode === "window") ||
90
+ (want === "os-window" &&
91
+ (packed.mode === "os-window" || packed.mode === "os-window-fallback:window"))
92
+ ? []
93
+ : holes,
94
+ };
95
+ }
96
+ async function packHumanScreenshots(args) {
97
+ const { session, pageUrl, contentPng, want, maxW } = args;
98
+ if (want === "content") {
99
+ return { primary: contentPng, mode: "content", contentPng };
100
+ }
101
+ if (want === "os-window") {
102
+ const os = await captureOsWindow(session.app, pageUrl);
103
+ let osPng = os.png;
104
+ if (osPng)
105
+ osPng = downscaleIfNeeded(osPng, maxW);
106
+ if (osPng) {
107
+ return { primary: osPng, mode: "os-window" };
108
+ }
109
+ // Fall back to composited window, then content.
110
+ const win = await captureWindow(session.app, pageUrl);
111
+ let windowPng = win.png;
112
+ if (windowPng)
113
+ windowPng = downscaleIfNeeded(windowPng, maxW);
114
+ if (windowPng) {
115
+ return {
116
+ primary: windowPng,
117
+ mode: "os-window-fallback:window",
118
+ note: os.note ?? "os-window capture unavailable; used window",
119
+ };
120
+ }
121
+ return {
122
+ primary: contentPng,
123
+ mode: "content-fallback",
124
+ note: [os.note, win.note].filter(Boolean).join("; ") ||
125
+ "os-window and window capture unavailable",
126
+ };
127
+ }
128
+ const win = await captureWindow(session.app, pageUrl);
129
+ let windowPng = win.png;
130
+ if (windowPng) {
131
+ windowPng = downscaleIfNeeded(windowPng, maxW);
132
+ }
133
+ if (want === "both") {
134
+ if (windowPng) {
135
+ return {
136
+ primary: windowPng,
137
+ mode: "window",
138
+ windowPng,
139
+ contentPng,
140
+ };
141
+ }
142
+ return {
143
+ primary: contentPng,
144
+ mode: "content-fallback",
145
+ note: win.note ?? "window capture unavailable",
146
+ contentPng,
147
+ };
148
+ }
149
+ // want === "window"
150
+ if (windowPng) {
151
+ return {
152
+ primary: windowPng,
153
+ mode: "window",
154
+ // No hole annotation — composited capture shows real pixels.
155
+ };
156
+ }
157
+ return {
158
+ primary: contentPng,
159
+ mode: "content-fallback",
160
+ note: win.note ?? "window capture unavailable",
161
+ };
162
+ }
163
+ async function readDevicePixelRatio(page) {
164
+ try {
165
+ const dpr = await page.evaluate(() => window.devicePixelRatio || 1);
166
+ return typeof dpr === "number" && dpr > 0 ? dpr : 1;
167
+ }
168
+ catch {
169
+ return 1;
170
+ }
171
+ }
172
+ /** Multiply DIP hole rects by devicePixelRatio for physical-pixel screenshot space. */
173
+ export function scaleHolesToPhysical(holes, dpr) {
174
+ if (dpr === 1)
175
+ return holes;
176
+ return holes.map((h) => ({
177
+ x: h.x * dpr,
178
+ y: h.y * dpr,
179
+ width: h.width * dpr,
180
+ height: h.height * dpr,
181
+ }));
182
+ }
@@ -0,0 +1,27 @@
1
+ import type { ElectronApplication } from "playwright-core";
2
+ export interface WindowCaptureResult {
3
+ png: Buffer | null;
4
+ note?: string;
5
+ }
6
+ /**
7
+ * Capture the composited Electron window via desktopCapturer window sources
8
+ * (menu bar + content; typically NO OS caption / min-max-close buttons).
9
+ *
10
+ * Returns null on permission failure, missing source, zero-size, or
11
+ * all-black/empty thumbnail so callers can fall back to content capture.
12
+ *
13
+ * Uses Playwright's `app.evaluate` first-arg Electron namespace (no require —
14
+ * the main-process evaluate context is ESM-like and `require` is undefined).
15
+ */
16
+ export declare function captureWindow(app: ElectronApplication, selectedPageUrl: string): Promise<WindowCaptureResult>;
17
+ /**
18
+ * Capture the FULL OS window including the caption bar (min/max/close buttons)
19
+ * and borders, by cropping a screen-source thumbnail to the window's outer
20
+ * bounds (getBounds includes the frame).
21
+ *
22
+ * Cross-platform, in-process, no native addon. Caveats:
23
+ * - Another window on top can intrude (we raise/focus best-effort first).
24
+ * - macOS needs Screen Recording permission (all-black → null → fallback).
25
+ * - Multi-monitor: uses getDisplayMatching + display-relative crop.
26
+ */
27
+ export declare function captureOsWindow(app: ElectronApplication, selectedPageUrl: string): Promise<WindowCaptureResult>;
@@ -0,0 +1,236 @@
1
+ import { PNG } from "pngjs";
2
+ /**
3
+ * Capture the composited Electron window via desktopCapturer window sources
4
+ * (menu bar + content; typically NO OS caption / min-max-close buttons).
5
+ *
6
+ * Returns null on permission failure, missing source, zero-size, or
7
+ * all-black/empty thumbnail so callers can fall back to content capture.
8
+ *
9
+ * Uses Playwright's `app.evaluate` first-arg Electron namespace (no require —
10
+ * the main-process evaluate context is ESM-like and `require` is undefined).
11
+ */
12
+ export async function captureWindow(app, selectedPageUrl) {
13
+ try {
14
+ const b64 = await app.evaluate(async (electron, pageUrl) => {
15
+ const { desktopCapturer, screen, BrowserWindow, } = electron;
16
+ const wins = BrowserWindow.getAllWindows().filter((w) => !w.isDestroyed());
17
+ const win = wins.find((w) => {
18
+ try {
19
+ return w.webContents.getURL() === pageUrl;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }) ?? wins[0];
25
+ if (!win)
26
+ return null;
27
+ if (win.isMinimized())
28
+ win.restore();
29
+ const b = win.getBounds();
30
+ if (b.width <= 0 || b.height <= 0)
31
+ return null;
32
+ const sf = screen.getDisplayMatching(b).scaleFactor || 1;
33
+ const sourceId = win.getMediaSourceId();
34
+ const sources = await desktopCapturer.getSources({
35
+ types: ["window"],
36
+ thumbnailSize: {
37
+ width: Math.ceil(b.width * sf),
38
+ height: Math.ceil(b.height * sf),
39
+ },
40
+ });
41
+ // Match by ID ONLY — never title (titles collide / change).
42
+ const src = sources.find((s) => s.id === sourceId);
43
+ if (!src)
44
+ return null;
45
+ if (src.thumbnail.isEmpty())
46
+ return null;
47
+ const size = src.thumbnail.getSize();
48
+ if (size.width < 2 || size.height < 2)
49
+ return null;
50
+ const pngBuf = src.thumbnail.toPNG();
51
+ // Buffer may be a Node Buffer or Uint8Array depending on Electron version.
52
+ const u8 = pngBuf instanceof Uint8Array ? pngBuf : new Uint8Array(pngBuf);
53
+ let binary = "";
54
+ const chunk = 0x8000;
55
+ for (let i = 0; i < u8.length; i += chunk) {
56
+ binary += String.fromCharCode(...u8.subarray(i, i + chunk));
57
+ }
58
+ return btoa(binary);
59
+ }, selectedPageUrl);
60
+ if (!b64 || typeof b64 !== "string") {
61
+ return {
62
+ png: null,
63
+ note: "window capture unavailable: no matching source (permission/minimized/offscreen)",
64
+ };
65
+ }
66
+ const png = Buffer.from(b64, "base64");
67
+ if (png.length < 50) {
68
+ return { png: null, note: "window capture unavailable: empty thumbnail" };
69
+ }
70
+ if (isAllBlackOrEmpty(png)) {
71
+ return {
72
+ png: null,
73
+ note: "window capture unavailable: all-black thumbnail (permission/offscreen)",
74
+ };
75
+ }
76
+ return { png };
77
+ }
78
+ catch (err) {
79
+ const msg = err instanceof Error ? err.message : String(err);
80
+ return {
81
+ png: null,
82
+ note: `window capture unavailable: ${msg.slice(0, 200)}`,
83
+ };
84
+ }
85
+ }
86
+ /**
87
+ * Capture the FULL OS window including the caption bar (min/max/close buttons)
88
+ * and borders, by cropping a screen-source thumbnail to the window's outer
89
+ * bounds (getBounds includes the frame).
90
+ *
91
+ * Cross-platform, in-process, no native addon. Caveats:
92
+ * - Another window on top can intrude (we raise/focus best-effort first).
93
+ * - macOS needs Screen Recording permission (all-black → null → fallback).
94
+ * - Multi-monitor: uses getDisplayMatching + display-relative crop.
95
+ */
96
+ export async function captureOsWindow(app, selectedPageUrl) {
97
+ try {
98
+ const b64 = await app.evaluate(async (electron, pageUrl) => {
99
+ const { desktopCapturer, screen, BrowserWindow, } = electron;
100
+ const wins = BrowserWindow.getAllWindows().filter((w) => !w.isDestroyed());
101
+ const win = wins.find((w) => {
102
+ try {
103
+ return w.webContents.getURL() === pageUrl;
104
+ }
105
+ catch {
106
+ return false;
107
+ }
108
+ }) ?? wins[0];
109
+ if (!win)
110
+ return null;
111
+ // Raise to avoid occlusion (best-effort).
112
+ try {
113
+ if (win.isMinimized())
114
+ win.restore();
115
+ win.show();
116
+ win.focus();
117
+ if (typeof win.moveTop === "function")
118
+ win.moveTop();
119
+ }
120
+ catch {
121
+ /* ignore raise failures */
122
+ }
123
+ // Brief tick so the compositor paints the raised window.
124
+ await new Promise((r) => setTimeout(r, 80));
125
+ const b = win.getBounds();
126
+ if (b.width <= 0 || b.height <= 0)
127
+ return null;
128
+ const display = screen.getDisplayMatching(b);
129
+ const sf = display.scaleFactor || 1;
130
+ const thumbW = Math.round(display.size.width * sf);
131
+ const thumbH = Math.round(display.size.height * sf);
132
+ if (thumbW < 2 || thumbH < 2)
133
+ return null;
134
+ const sources = await desktopCapturer.getSources({
135
+ types: ["screen"],
136
+ thumbnailSize: { width: thumbW, height: thumbH },
137
+ });
138
+ if (!sources.length)
139
+ return null;
140
+ const displayIdStr = String(display.id);
141
+ const src = sources.find((s) => s.display_id === displayIdStr) ?? sources[0];
142
+ if (!src || src.thumbnail.isEmpty())
143
+ return null;
144
+ // Window rect relative to the display, in physical pixels.
145
+ const cropX = Math.round((b.x - display.bounds.x) * sf);
146
+ const cropY = Math.round((b.y - display.bounds.y) * sf);
147
+ const cropW = Math.round(b.width * sf);
148
+ const cropH = Math.round(b.height * sf);
149
+ const thumbSize = src.thumbnail.getSize();
150
+ // Reject crop that is outside the thumbnail / zero-size.
151
+ if (cropW < 2 ||
152
+ cropH < 2 ||
153
+ cropX + cropW <= 0 ||
154
+ cropY + cropH <= 0 ||
155
+ cropX >= thumbSize.width ||
156
+ cropY >= thumbSize.height) {
157
+ return null;
158
+ }
159
+ // Clamp crop to thumbnail bounds (partial off-screen windows).
160
+ const x = Math.max(0, cropX);
161
+ const y = Math.max(0, cropY);
162
+ const width = Math.min(cropW - (x - cropX), thumbSize.width - x);
163
+ const height = Math.min(cropH - (y - cropY), thumbSize.height - y);
164
+ if (width < 2 || height < 2)
165
+ return null;
166
+ const cropped = src.thumbnail.crop({ x, y, width, height });
167
+ if (cropped.isEmpty())
168
+ return null;
169
+ const cSize = cropped.getSize();
170
+ if (cSize.width < 2 || cSize.height < 2)
171
+ return null;
172
+ const pngBuf = cropped.toPNG();
173
+ const u8 = pngBuf instanceof Uint8Array ? pngBuf : new Uint8Array(pngBuf);
174
+ let binary = "";
175
+ const chunk = 0x8000;
176
+ for (let i = 0; i < u8.length; i += chunk) {
177
+ binary += String.fromCharCode(...u8.subarray(i, i + chunk));
178
+ }
179
+ return btoa(binary);
180
+ }, selectedPageUrl);
181
+ if (!b64 || typeof b64 !== "string") {
182
+ return {
183
+ png: null,
184
+ note: "os-window capture unavailable: no screen source / crop failed (permission/minimized/offscreen)",
185
+ };
186
+ }
187
+ const png = Buffer.from(b64, "base64");
188
+ if (png.length < 50) {
189
+ return { png: null, note: "os-window capture unavailable: empty crop" };
190
+ }
191
+ if (isAllBlackOrEmpty(png)) {
192
+ return {
193
+ png: null,
194
+ note: "os-window capture unavailable: all-black image (Screen Recording permission / offscreen)",
195
+ };
196
+ }
197
+ return { png };
198
+ }
199
+ catch (err) {
200
+ const msg = err instanceof Error ? err.message : String(err);
201
+ return {
202
+ png: null,
203
+ note: `os-window capture unavailable: ${msg.slice(0, 200)}`,
204
+ };
205
+ }
206
+ }
207
+ /** Detect zero-size / fully black-or-transparent captures (macOS permission). */
208
+ function isAllBlackOrEmpty(pngBuf) {
209
+ try {
210
+ const img = PNG.sync.read(pngBuf);
211
+ if (img.width < 2 || img.height < 2)
212
+ return true;
213
+ let nonBlack = 0;
214
+ let samples = 0;
215
+ const stepX = Math.max(1, Math.floor(img.width / 16));
216
+ const stepY = Math.max(1, Math.floor(img.height / 16));
217
+ for (let y = 0; y < img.height; y += stepY) {
218
+ for (let x = 0; x < img.width; x += stepX) {
219
+ const i = (img.width * y + x) << 2;
220
+ const r = img.data[i];
221
+ const g = img.data[i + 1];
222
+ const b = img.data[i + 2];
223
+ const a = img.data[i + 3];
224
+ samples++;
225
+ if (a > 16 && (r > 8 || g > 8 || b > 8))
226
+ nonBlack++;
227
+ }
228
+ }
229
+ if (samples === 0)
230
+ return true;
231
+ return nonBlack / samples < 0.01;
232
+ }
233
+ catch {
234
+ return true;
235
+ }
236
+ }
@@ -0,0 +1,29 @@
1
+ import type { ElectronApplication, Page } from "playwright-core";
2
+ import type { HoleRect } from "./types.js";
3
+ export interface SelectWindowOptions {
4
+ /**
5
+ * Substring or full URL that the target Page must match.
6
+ * When set, takes priority over the non-http heuristic.
7
+ */
8
+ windowUrlMatch?: string;
9
+ }
10
+ export interface SelectWindowResult {
11
+ page: Page;
12
+ /** How the window was chosen (for diagnostics). */
13
+ strategy: "windowUrlMatch" | "nonHttpUrl" | "mainProcessTop" | "firstWindow";
14
+ warning?: string;
15
+ }
16
+ /**
17
+ * Pick the app-owned top-level window rather than an embedded BrowserView.
18
+ *
19
+ * Pursuit (and similar shells) expose the BrowserView (https://…) first;
20
+ * `firstWindow()` returns that. The real app window is often a `data:` URL
21
+ * (or file://). Prefer non-http(s) pages, cross-check via main-process
22
+ * BrowserWindow, allow config override.
23
+ */
24
+ export declare function selectTargetWindow(app: ElectronApplication, opts?: SelectWindowOptions): Promise<SelectWindowResult>;
25
+ /**
26
+ * Read BrowserView bounds from the main process for the window whose
27
+ * webContents URL matches `selectedPageUrl` (falls back to first window).
28
+ */
29
+ export declare function detectHoles(app: ElectronApplication, selectedPageUrl?: string): Promise<HoleRect[]>;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Pick the app-owned top-level window rather than an embedded BrowserView.
3
+ *
4
+ * Pursuit (and similar shells) expose the BrowserView (https://…) first;
5
+ * `firstWindow()` returns that. The real app window is often a `data:` URL
6
+ * (or file://). Prefer non-http(s) pages, cross-check via main-process
7
+ * BrowserWindow, allow config override.
8
+ */
9
+ export async function selectTargetWindow(app, opts = {}) {
10
+ const pages = app.windows();
11
+ if (pages.length === 0) {
12
+ const page = await app.firstWindow({ timeout: 30_000 });
13
+ return {
14
+ page,
15
+ strategy: "firstWindow",
16
+ warning: "No windows listed yet; fell back to firstWindow().",
17
+ };
18
+ }
19
+ // 1. Explicit config override.
20
+ if (opts.windowUrlMatch) {
21
+ const needle = opts.windowUrlMatch;
22
+ const match = pages.find((p) => p.url().includes(needle));
23
+ if (match) {
24
+ return { page: match, strategy: "windowUrlMatch" };
25
+ }
26
+ }
27
+ // 2. Prefer app-owned top-level: URL does NOT match ^https?:
28
+ // Verified on Pursuit: data: URL shell vs https BrowserView.
29
+ const nonHttp = pages.find((p) => !/^https?:/i.test(p.url()));
30
+ if (nonHttp) {
31
+ return { page: nonHttp, strategy: "nonHttpUrl" };
32
+ }
33
+ // 3. Cross-check via main process: match BrowserWindow by selected heuristics.
34
+ try {
35
+ const topUrl = await app.evaluate(({ BrowserWindow }) => {
36
+ const wins = BrowserWindow.getAllWindows();
37
+ // Prefer the focused / first non-destroyed top-level window.
38
+ const w = wins.find((x) => !x.isDestroyed()) ?? wins[0];
39
+ return w ? w.webContents.getURL() : null;
40
+ });
41
+ if (typeof topUrl === "string" && topUrl.length > 0) {
42
+ const match = pages.find((p) => p.url() === topUrl);
43
+ if (match) {
44
+ return { page: match, strategy: "mainProcessTop" };
45
+ }
46
+ }
47
+ }
48
+ catch {
49
+ // Main-process evaluate can fail if the app is mid-quit; fall through.
50
+ }
51
+ // 4. Last resort.
52
+ const page = pages[0] ?? (await app.firstWindow({ timeout: 5_000 }));
53
+ return {
54
+ page,
55
+ strategy: "firstWindow",
56
+ warning: "Could not identify app-owned window via non-http URL or main process; using firstWindow().",
57
+ };
58
+ }
59
+ /**
60
+ * Read BrowserView bounds from the main process for the window whose
61
+ * webContents URL matches `selectedPageUrl` (falls back to first window).
62
+ */
63
+ export async function detectHoles(app, selectedPageUrl) {
64
+ try {
65
+ const holes = await app.evaluate(({ BrowserWindow }, pageUrl) => {
66
+ const wins = BrowserWindow.getAllWindows().filter((w) => !w.isDestroyed());
67
+ const w = (pageUrl
68
+ ? wins.find((x) => {
69
+ try {
70
+ return x.webContents.getURL() === pageUrl;
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ })
76
+ : undefined) ?? wins[0];
77
+ if (!w)
78
+ return [];
79
+ const views = typeof w.getBrowserViews === "function"
80
+ ? w.getBrowserViews()
81
+ : [];
82
+ return views.map((v) => v.getBounds());
83
+ }, selectedPageUrl ?? null);
84
+ return (holes ?? []).filter((h) => typeof h?.x === "number" &&
85
+ typeof h?.y === "number" &&
86
+ typeof h?.width === "number" &&
87
+ typeof h?.height === "number" &&
88
+ h.width > 0 &&
89
+ h.height > 0);
90
+ }
91
+ catch {
92
+ return [];
93
+ }
94
+ }
package/dist/mcp.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};