@archastro/movie-harness 0.1.0 → 0.2.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/dist/cli.js +2 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/source-help.d.ts +3 -0
- package/dist/source-help.js +12 -3
- package/dist/sources/desktop-macos.d.ts +22 -0
- package/dist/sources/desktop-macos.js +130 -5
- package/native/macos/WindowTools.swift +18 -4
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -30,7 +30,7 @@ run --source …
|
|
|
30
30
|
pty --fixture path.yaml|json # truecolor TUI — preferred for terminals
|
|
31
31
|
pty-demo [--color 7c5cff] # truecolor smoke, no program
|
|
32
32
|
desktop.window --bundle-id ID | --window-id N | --title-regex RE | --owner NAME | --pid N
|
|
33
|
-
[--duration-ms 3000] [--fps 10] [--cursor]
|
|
33
|
+
[--duration-ms 3000] [--fps 10] [--cursor] [--allow-blank]
|
|
34
34
|
frames [--demo-frames N] # or use start/push-frame/stop
|
|
35
35
|
|
|
36
36
|
Common options
|
|
@@ -362,6 +362,7 @@ async function cmdRun(args) {
|
|
|
362
362
|
match,
|
|
363
363
|
durationMs,
|
|
364
364
|
cursor: Boolean(args.cursor),
|
|
365
|
+
allowBlank: Boolean(args["allow-blank"]),
|
|
365
366
|
fps: common.fps ?? 10,
|
|
366
367
|
});
|
|
367
368
|
process.stdout.write(`${JSON.stringify(artifact, null, 2)}\n`);
|
package/dist/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export { finalizeManifest, sinkMovie } from "./sink.js";
|
|
|
6
6
|
export { formatSourceCatalog, recommendSource, SOURCE_CATALOG, SOURCE_DECISION_TABLE, sourceHintForError, } from "./source-help.js";
|
|
7
7
|
export type { SourceAdvice, SourceKindHelp } from "./source-help.js";
|
|
8
8
|
export { recordBrowserMovie } from "./sources/browser.js";
|
|
9
|
-
export { assertDesktopToolchain, checkScreenRecordingAccess, desktopMatchFromFlags, ensureScreenRecordingAccess, formatScreenRecordingDeniedHelp, listDesktopWindows, matchDesktopWindow, openScreenRecordingSettings, recordDesktopWindowMovie, } from "./sources/desktop-macos.js";
|
|
9
|
+
export { assertDesktopToolchain, checkScreenRecordingAccess, describeDesktopWindow, desktopMatchFromFlags, ensureScreenRecordingAccess, formatScreenRecordingDeniedHelp, isNearlyBlankPng, listDesktopWindows, matchDesktopWindow, openScreenRecordingSettings, recordDesktopWindowMovie, } from "./sources/desktop-macos.js";
|
|
10
10
|
export type { DesktopWindowInfo, DesktopWindowMatch, DesktopWindowMovieOptions, ScreenAccessReport, } from "./sources/desktop-macos.js";
|
|
11
11
|
export { loadFrameSession, markFrameSession, pushFrameToSession, startFrameSession, stopFrameSession, } from "./sources/frames-store.js";
|
|
12
12
|
export { loadPtyMovieFixture, recordPtyMovie, recordTruecolorDemoMovie, } from "./sources/pty.js";
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ export { MovieSession } from "./session.js";
|
|
|
5
5
|
export { finalizeManifest, sinkMovie } from "./sink.js";
|
|
6
6
|
export { formatSourceCatalog, recommendSource, SOURCE_CATALOG, SOURCE_DECISION_TABLE, sourceHintForError, } from "./source-help.js";
|
|
7
7
|
export { recordBrowserMovie } from "./sources/browser.js";
|
|
8
|
-
export { assertDesktopToolchain, checkScreenRecordingAccess, desktopMatchFromFlags, ensureScreenRecordingAccess, formatScreenRecordingDeniedHelp, listDesktopWindows, matchDesktopWindow, openScreenRecordingSettings, recordDesktopWindowMovie, } from "./sources/desktop-macos.js";
|
|
8
|
+
export { assertDesktopToolchain, checkScreenRecordingAccess, describeDesktopWindow, desktopMatchFromFlags, ensureScreenRecordingAccess, formatScreenRecordingDeniedHelp, isNearlyBlankPng, listDesktopWindows, matchDesktopWindow, openScreenRecordingSettings, recordDesktopWindowMovie, } from "./sources/desktop-macos.js";
|
|
9
9
|
export { loadFrameSession, markFrameSession, pushFrameToSession, startFrameSession, stopFrameSession, } from "./sources/frames-store.js";
|
|
10
10
|
export { loadPtyMovieFixture, recordPtyMovie, recordTruecolorDemoMovie, } from "./sources/pty.js";
|
|
11
11
|
export { createHeadlessTerminal, terminalPlainText, terminalToHtml, writeTerminal, } from "./terminal-paint.js";
|
package/dist/source-help.d.ts
CHANGED
|
@@ -19,6 +19,9 @@ export declare function formatSourceCatalog(): string;
|
|
|
19
19
|
/**
|
|
20
20
|
* Lightweight advisor for agents: pass free-text intent, get a recommended source.
|
|
21
21
|
* This is heuristic — the decision table is authoritative.
|
|
22
|
+
*
|
|
23
|
+
* Order matters: TUI beats native (don't desktop Terminal.app); native/menu-bar
|
|
24
|
+
* beats browser so "record the Astroshots tray" never defaults to Chromium.
|
|
22
25
|
*/
|
|
23
26
|
export declare function recommendSource(intent: string): {
|
|
24
27
|
source: SourceKindHelp;
|
package/dist/source-help.js
CHANGED
|
@@ -15,6 +15,7 @@ Pick the FIRST row that matches the thing you need to record:
|
|
|
15
15
|
| A TUI / CLI / Ink / Ratatui / truecolor terminal | pty | SGR→xterm truecolor path; NEVER screenshot Terminal.app |
|
|
16
16
|
| Color-critical terminal (brand purple, etc.) | pty | Host terminal themes remapping 16 colors would lie |
|
|
17
17
|
| A native macOS app window (SwiftUI, Electron…) | desktop.window | Real window pixels via screencapture; needs Screen Recording |
|
|
18
|
+
| Menu-bar / tray / status-item / LSUIElement app | desktop.window | Real app chrome; list-windows may include popover layers |
|
|
18
19
|
| The whole monitor / multi-window desktop | desktop.display | (not implemented yet — use desktop.window or frames) |
|
|
19
20
|
| Frames from any other tool (Unity, remote, custom)| frames | You push PNG/JPEG; harness only encodes + sinks |
|
|
20
21
|
| You already have PNG frames on disk | frames | Multi-process start/push-frame/stop |
|
|
@@ -104,6 +105,7 @@ export const SOURCE_CATALOG = {
|
|
|
104
105
|
summary: "macOS native window pixels via CGWindowList + screencapture -l sampling.",
|
|
105
106
|
useWhen: [
|
|
106
107
|
"SwiftUI / AppKit / Electron / any real Mac window",
|
|
108
|
+
"Menu-bar / tray / status-item / LSUIElement apps (e.g. Astroshots)",
|
|
107
109
|
"You need the actual app chrome and OS rendering",
|
|
108
110
|
],
|
|
109
111
|
neverWhen: [
|
|
@@ -177,6 +179,9 @@ export function formatSourceCatalog() {
|
|
|
177
179
|
/**
|
|
178
180
|
* Lightweight advisor for agents: pass free-text intent, get a recommended source.
|
|
179
181
|
* This is heuristic — the decision table is authoritative.
|
|
182
|
+
*
|
|
183
|
+
* Order matters: TUI beats native (don't desktop Terminal.app); native/menu-bar
|
|
184
|
+
* beats browser so "record the Astroshots tray" never defaults to Chromium.
|
|
180
185
|
*/
|
|
181
186
|
export function recommendSource(intent) {
|
|
182
187
|
const text = intent.toLowerCase();
|
|
@@ -186,9 +191,13 @@ export function recommendSource(intent) {
|
|
|
186
191
|
reason: "Terminal/TUI intent detected — use pty for truecolor SGR fidelity (not desktop of a terminal app).",
|
|
187
192
|
};
|
|
188
193
|
}
|
|
189
|
-
if (/\b(swiftui|appkit|electron|native app|macos app|mac app|native mac|menu ?bar|desktop window|bundle[- ]?id|window id)\b/.test(text) ||
|
|
190
|
-
|
|
191
|
-
|
|
194
|
+
if (/\b(swiftui|appkit|electron|native app|macos app|mac app|native mac|menu[- ]?bar|menubar|status[- ]?item|lsuielement|popover|desktop window|bundle[- ]?id|window id|window[- ]?id)\b/.test(text) ||
|
|
195
|
+
// "tray" alone is ambiguous (web trays exist); with an app name / menu-bar
|
|
196
|
+
// product context, treat as native desktop capture.
|
|
197
|
+
/\b(tray|menu bar tray)\b/.test(text) ||
|
|
198
|
+
/\bastroshots?\b/.test(text) ||
|
|
199
|
+
(/\bnative\b/.test(text) &&
|
|
200
|
+
/\b(window|app|desktop|macos|mac|chrome|ui)\b/.test(text))) {
|
|
192
201
|
return {
|
|
193
202
|
source: "desktop.window",
|
|
194
203
|
reason: "Native desktop window intent detected — use desktop.window with --bundle-id or --window-id.",
|
|
@@ -10,6 +10,8 @@ export interface DesktopWindowInfo {
|
|
|
10
10
|
x: number;
|
|
11
11
|
y: number;
|
|
12
12
|
onScreen: boolean;
|
|
13
|
+
/** CGWindow layer; 0 = normal, >0 = floating/popover chrome. */
|
|
14
|
+
layer?: number;
|
|
13
15
|
}
|
|
14
16
|
export interface DesktopWindowMatch {
|
|
15
17
|
windowId?: number;
|
|
@@ -19,6 +21,11 @@ export interface DesktopWindowMatch {
|
|
|
19
21
|
pid?: number;
|
|
20
22
|
/** When multiple match, pick largest (default) or first. */
|
|
21
23
|
pick?: "largest" | "first";
|
|
24
|
+
/**
|
|
25
|
+
* Prefer on-screen windows when several match (default true).
|
|
26
|
+
* Off-screen / empty host windows often produce black frames.
|
|
27
|
+
*/
|
|
28
|
+
preferOnScreen?: boolean;
|
|
22
29
|
}
|
|
23
30
|
export type DesktopWindowMovieOptions = Omit<MovieSessionOptions, "source"> & {
|
|
24
31
|
match: DesktopWindowMatch;
|
|
@@ -26,6 +33,11 @@ export type DesktopWindowMovieOptions = Omit<MovieSessionOptions, "source"> & {
|
|
|
26
33
|
durationMs?: number;
|
|
27
34
|
/** Include cursor in frames (screencapture -C). Default false. */
|
|
28
35
|
cursor?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Allow mostly-blank / black posters to encode (default false).
|
|
38
|
+
* Off-screen windows and denied Screen Recording often produce black frames.
|
|
39
|
+
*/
|
|
40
|
+
allowBlank?: boolean;
|
|
29
41
|
};
|
|
30
42
|
export interface ScreenAccessReport {
|
|
31
43
|
granted: boolean;
|
|
@@ -70,6 +82,16 @@ export declare function ensureScreenRecordingAccess(options?: {
|
|
|
70
82
|
/** List layer-0 windows as JSON via shipped Swift tool (interpreted by `swift`). */
|
|
71
83
|
export declare function listDesktopWindows(): DesktopWindowInfo[];
|
|
72
84
|
export declare function matchDesktopWindow(windows: DesktopWindowInfo[], match: DesktopWindowMatch): DesktopWindowInfo;
|
|
85
|
+
/** Human-readable manifest description for a captured window. */
|
|
86
|
+
export declare function describeDesktopWindow(target: DesktopWindowInfo): string;
|
|
87
|
+
/**
|
|
88
|
+
* True when a PNG is almost entirely very dark (typical failed/off-screen capture).
|
|
89
|
+
* Samples up to ~4k pixels across a simple grid; supports 8-bit RGB/RGBA.
|
|
90
|
+
*/
|
|
91
|
+
export declare function isNearlyBlankPng(filePath: string, options?: {
|
|
92
|
+
maxMeanLuma?: number;
|
|
93
|
+
minDarkFraction?: number;
|
|
94
|
+
}): boolean;
|
|
73
95
|
/**
|
|
74
96
|
* Sample a macOS window at `fps` for `durationMs`, encode to movie + poster.
|
|
75
97
|
* Uses OS `screencapture` (already on every Mac) — no separate download.
|
|
@@ -3,6 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import zlib from "node:zlib";
|
|
6
7
|
import { MovieSession } from "../session.js";
|
|
7
8
|
const SCREENCAPTURE = "/usr/sbin/screencapture";
|
|
8
9
|
/** Known deep-links to the Screen Recording privacy pane (varies by macOS). */
|
|
@@ -200,16 +201,126 @@ export function matchDesktopWindow(windows, match) {
|
|
|
200
201
|
if (candidates.length === 0) {
|
|
201
202
|
const sample = windows
|
|
202
203
|
.slice(0, 8)
|
|
203
|
-
.map((w) => ` id=${w.id} pid=${w.pid} bundle=${w.bundleId ?? "?"} owner=${JSON.stringify(w.owner)} title=${JSON.stringify(w.title)} ${w.width}x${w.height}`)
|
|
204
|
+
.map((w) => ` id=${w.id} pid=${w.pid} layer=${w.layer ?? "?"} bundle=${w.bundleId ?? "?"} owner=${JSON.stringify(w.owner)} title=${JSON.stringify(w.title)} ${w.width}x${w.height} onScreen=${w.onScreen}`)
|
|
204
205
|
.join("\n");
|
|
205
206
|
throw new Error(`No desktop window matched ${JSON.stringify(match)}.\n` +
|
|
206
207
|
`Run: astroshot movie list-windows\n` +
|
|
207
208
|
`Sample windows:\n${sample || " (none)"}`);
|
|
208
209
|
}
|
|
210
|
+
const preferOnScreen = match.preferOnScreen !== false;
|
|
211
|
+
if (preferOnScreen && match.windowId === undefined) {
|
|
212
|
+
const onScreen = candidates.filter((w) => w.onScreen);
|
|
213
|
+
if (onScreen.length > 0)
|
|
214
|
+
candidates = onScreen;
|
|
215
|
+
}
|
|
209
216
|
if (match.pick === "first")
|
|
210
217
|
return candidates[0];
|
|
211
|
-
// Default largest
|
|
212
|
-
return [...candidates].sort((a, b) =>
|
|
218
|
+
// Default: largest area, then lower layer (normal windows over floaters).
|
|
219
|
+
return [...candidates].sort((a, b) => {
|
|
220
|
+
const area = b.width * b.height - a.width * a.height;
|
|
221
|
+
if (area !== 0)
|
|
222
|
+
return area;
|
|
223
|
+
return (a.layer ?? 0) - (b.layer ?? 0);
|
|
224
|
+
})[0];
|
|
225
|
+
}
|
|
226
|
+
/** Human-readable manifest description for a captured window. */
|
|
227
|
+
export function describeDesktopWindow(target) {
|
|
228
|
+
const name = target.bundleId?.split(".").pop() || target.owner || "window";
|
|
229
|
+
const title = target.title.trim();
|
|
230
|
+
const size = `${target.width}×${target.height}`;
|
|
231
|
+
const where = target.onScreen ? "on-screen" : "off-screen";
|
|
232
|
+
const layer = target.layer !== undefined && target.layer > 0
|
|
233
|
+
? `, layer ${target.layer}`
|
|
234
|
+
: "";
|
|
235
|
+
if (title) {
|
|
236
|
+
return `${name}: “${title}” (${size}, ${where}${layer})`;
|
|
237
|
+
}
|
|
238
|
+
return `${name} window (${size}, ${where}${layer})`;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* True when a PNG is almost entirely very dark (typical failed/off-screen capture).
|
|
242
|
+
* Samples up to ~4k pixels across a simple grid; supports 8-bit RGB/RGBA.
|
|
243
|
+
*/
|
|
244
|
+
export function isNearlyBlankPng(filePath, options) {
|
|
245
|
+
const maxMeanLuma = options?.maxMeanLuma ?? 12;
|
|
246
|
+
const minDarkFraction = options?.minDarkFraction ?? 0.97;
|
|
247
|
+
let data;
|
|
248
|
+
try {
|
|
249
|
+
data = fs.readFileSync(filePath);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
if (data.length < 33 || data.toString("ascii", 1, 4) !== "PNG")
|
|
255
|
+
return true;
|
|
256
|
+
// Minimal PNG scan: find IDAT chunks, inflate, average luma on a grid.
|
|
257
|
+
const width = data.readUInt32BE(16);
|
|
258
|
+
const height = data.readUInt32BE(20);
|
|
259
|
+
const bitDepth = data[24];
|
|
260
|
+
const colorType = data[25];
|
|
261
|
+
if (!width || !height || bitDepth !== 8)
|
|
262
|
+
return false;
|
|
263
|
+
// 2 = RGB, 6 = RGBA
|
|
264
|
+
if (colorType !== 2 && colorType !== 6)
|
|
265
|
+
return false;
|
|
266
|
+
const channels = colorType === 6 ? 4 : 3;
|
|
267
|
+
const idatParts = [];
|
|
268
|
+
let offset = 8;
|
|
269
|
+
while (offset + 8 <= data.length) {
|
|
270
|
+
const len = data.readUInt32BE(offset);
|
|
271
|
+
const type = data.toString("ascii", offset + 4, offset + 8);
|
|
272
|
+
const start = offset + 8;
|
|
273
|
+
const end = start + len;
|
|
274
|
+
if (end + 4 > data.length)
|
|
275
|
+
break;
|
|
276
|
+
if (type === "IDAT")
|
|
277
|
+
idatParts.push(data.subarray(start, end));
|
|
278
|
+
if (type === "IEND")
|
|
279
|
+
break;
|
|
280
|
+
offset = end + 4;
|
|
281
|
+
}
|
|
282
|
+
if (idatParts.length === 0)
|
|
283
|
+
return true;
|
|
284
|
+
let inflated;
|
|
285
|
+
try {
|
|
286
|
+
inflated = zlib.inflateSync(Buffer.concat(idatParts));
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return false; // can't decode — don't claim blank
|
|
290
|
+
}
|
|
291
|
+
const stride = 1 + width * channels; // filter byte + row
|
|
292
|
+
const expected = stride * height;
|
|
293
|
+
if (inflated.length < expected)
|
|
294
|
+
return false;
|
|
295
|
+
// Only sample rows that use filter type 0 (None) for correct RGB bytes.
|
|
296
|
+
// For filtered rows, still sample raw bytes as a coarse darkness heuristic.
|
|
297
|
+
let samples = 0;
|
|
298
|
+
let dark = 0;
|
|
299
|
+
let lumaSum = 0;
|
|
300
|
+
const stepY = Math.max(1, Math.floor(height / 32));
|
|
301
|
+
const stepX = Math.max(1, Math.floor(width / 32));
|
|
302
|
+
for (let y = 0; y < height; y += stepY) {
|
|
303
|
+
const rowStart = y * stride;
|
|
304
|
+
const filter = inflated[rowStart] ?? 0;
|
|
305
|
+
for (let x = 0; x < width; x += stepX) {
|
|
306
|
+
const i = rowStart + 1 + x * channels;
|
|
307
|
+
const r = inflated[i] ?? 0;
|
|
308
|
+
const g = inflated[i + 1] ?? 0;
|
|
309
|
+
const b = inflated[i + 2] ?? 0;
|
|
310
|
+
// filter≠0 means bytes aren't raw RGB; still treat very low triples as dark.
|
|
311
|
+
const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
312
|
+
lumaSum += luma;
|
|
313
|
+
if (luma <= maxMeanLuma)
|
|
314
|
+
dark += 1;
|
|
315
|
+
samples += 1;
|
|
316
|
+
void filter;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (samples === 0)
|
|
320
|
+
return true;
|
|
321
|
+
const mean = lumaSum / samples;
|
|
322
|
+
const darkFraction = dark / samples;
|
|
323
|
+
return mean <= maxMeanLuma && darkFraction >= minDarkFraction;
|
|
213
324
|
}
|
|
214
325
|
function captureWindowPng(windowId, outPath, cursor) {
|
|
215
326
|
const finalArgs = cursor
|
|
@@ -279,6 +390,13 @@ export async function recordDesktopWindowMovie(options) {
|
|
|
279
390
|
ensureScreenRecordingAccess({ request: true, openSettings: true });
|
|
280
391
|
const windows = listDesktopWindows();
|
|
281
392
|
const target = matchDesktopWindow(windows, options.match);
|
|
393
|
+
if (!target.onScreen && !options.allowBlank) {
|
|
394
|
+
throw new Error(`Matched window id=${target.id} (${target.bundleId ?? target.owner}) is off-screen ` +
|
|
395
|
+
`(${target.width}×${target.height} at ${target.x},${target.y}). ` +
|
|
396
|
+
"Off-screen windows usually produce black movies. Bring the window on-screen " +
|
|
397
|
+
"(open the tray/popover) or pass --allow-blank to record anyway.\n" +
|
|
398
|
+
`Hint: ${describeDesktopWindow(target)}`);
|
|
399
|
+
}
|
|
282
400
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "astroshot-desktop-"));
|
|
283
401
|
const probe = path.join(tmp, "probe.png");
|
|
284
402
|
try {
|
|
@@ -288,6 +406,14 @@ export async function recordDesktopWindowMovie(options) {
|
|
|
288
406
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
289
407
|
throw error;
|
|
290
408
|
}
|
|
409
|
+
if (!options.allowBlank && isNearlyBlankPng(probe)) {
|
|
410
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
411
|
+
throw new Error(`Probe frame for window id=${target.id} is nearly blank/black. ` +
|
|
412
|
+
"Screen Recording may be denied for this host app, the window may be empty, " +
|
|
413
|
+
"or the popover may not be visible. Fix visibility/TCC, or pass --allow-blank.\n" +
|
|
414
|
+
`Window: ${describeDesktopWindow(target)}\n` +
|
|
415
|
+
"Run: astroshot movie check-screen-access");
|
|
416
|
+
}
|
|
291
417
|
const probed = readPngSize(probe);
|
|
292
418
|
const size = options.size ??
|
|
293
419
|
probed ?? {
|
|
@@ -299,8 +425,7 @@ export async function recordDesktopWindowMovie(options) {
|
|
|
299
425
|
size,
|
|
300
426
|
fps,
|
|
301
427
|
source: "desktop.window",
|
|
302
|
-
description: options.description ??
|
|
303
|
-
`desktop.window id=${target.id} ${target.bundleId ?? target.owner} ${JSON.stringify(target.title)}`,
|
|
428
|
+
description: options.description ?? describeDesktopWindow(target),
|
|
304
429
|
});
|
|
305
430
|
// Seed with probe frame so we never end empty if duration is tiny.
|
|
306
431
|
session.pushFrame(fs.readFileSync(probe));
|
|
@@ -21,6 +21,8 @@ struct WindowRow: Encodable {
|
|
|
21
21
|
let x: Int
|
|
22
22
|
let y: Int
|
|
23
23
|
let onScreen: Bool
|
|
24
|
+
/// CGWindow layer (0 = normal). Popovers / floating chrome often use > 0.
|
|
25
|
+
let layer: Int
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
struct ScreenAccessReport: Encodable {
|
|
@@ -50,7 +52,10 @@ func listWindows() -> [WindowRow] {
|
|
|
50
52
|
var rows: [WindowRow] = []
|
|
51
53
|
for w in info {
|
|
52
54
|
let layer = w[kCGWindowLayer as String] as? Int ?? -1
|
|
53
|
-
|
|
55
|
+
// Layer 0 = normal windows. Menu-bar trays / NSPopover often sit above 0
|
|
56
|
+
// (floating ~3, modal panel ~8). Skip Dock / main menu / status chrome
|
|
57
|
+
// (typically ≥20) which are huge, blank under screencapture, or unusable.
|
|
58
|
+
guard layer >= 0, layer <= 15 else { continue }
|
|
54
59
|
|
|
55
60
|
let id = w[kCGWindowNumber as String] as? Int ?? 0
|
|
56
61
|
let pid = w[kCGWindowOwnerPID as String] as? Int ?? 0
|
|
@@ -62,6 +67,7 @@ func listWindows() -> [WindowRow] {
|
|
|
62
67
|
let height = (bounds?["Height"] as? NSNumber)?.intValue ?? 0
|
|
63
68
|
let x = (bounds?["X"] as? NSNumber)?.intValue ?? 0
|
|
64
69
|
let y = (bounds?["Y"] as? NSNumber)?.intValue ?? 0
|
|
70
|
+
// Popovers can be small; keep a low floor but drop 1×1 placeholders.
|
|
65
71
|
if width < 2 || height < 2 { continue }
|
|
66
72
|
|
|
67
73
|
rows.append(
|
|
@@ -75,13 +81,21 @@ func listWindows() -> [WindowRow] {
|
|
|
75
81
|
height: height,
|
|
76
82
|
x: x,
|
|
77
83
|
y: y,
|
|
78
|
-
onScreen: onScreen
|
|
84
|
+
onScreen: onScreen,
|
|
85
|
+
layer: layer
|
|
79
86
|
)
|
|
80
87
|
)
|
|
81
88
|
}
|
|
82
89
|
|
|
83
|
-
//
|
|
84
|
-
|
|
90
|
+
// Prefer larger + on-screen + lower layer so normal windows win over
|
|
91
|
+
// tiny floating helpers when multiple match a bundle id.
|
|
92
|
+
return rows.sorted {
|
|
93
|
+
let area0 = $0.width * $0.height
|
|
94
|
+
let area1 = $1.width * $1.height
|
|
95
|
+
if area0 != area1 { return area0 > area1 }
|
|
96
|
+
if $0.onScreen != $1.onScreen { return $0.onScreen && !$1.onScreen }
|
|
97
|
+
return $0.layer < $1.layer
|
|
98
|
+
}
|
|
85
99
|
}
|
|
86
100
|
|
|
87
101
|
func hostIdentity() -> (name: String, bundleId: String?) {
|