Skip to content

Commit 7a14e91

Browse files
authored
Merge pull request #509 from chauhanabhay568/fix/overlay-visible-in-screen-share
fix(stealth): keep the meeting overlay hidden from screen captures
2 parents c0d3cc0 + 63a4fe9 commit 7a14e91

2 files changed

Lines changed: 267 additions & 21 deletions

File tree

electron/WindowHelper.ts

Lines changed: 44 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -269,14 +269,27 @@ export class WindowHelper {
269269
}
270270

271271
private applyContentProtection(enable: boolean): void {
272-
const windows = [
273-
this.launcherWindow,
274-
this.overlayWindow,
275-
this.pillWindow,
276-
this.toggleWindow,
277-
this.popoverCatcher,
278-
];
279-
windows.forEach((win) => {
272+
// The meeting overlay chrome (overlay body + pill + toggle) is a ghost
273+
// surface: it must NEVER appear in a screen capture, independent of
274+
// undetectable/dock mode. "Undetectable mode" governs Dock/taskbar
275+
// masquerading and the launcher's capture visibility — NOT the overlay's
276+
// screen-share invisibility, which is the app's core promise and is always
277+
// wanted while a meeting overlay is up. Coupling the two let the overlay
278+
// leak into a shared screen whenever undetectable mode was off (its
279+
// default), re-exposing it via `NSWindowSharingReadOnly` and overriding the
280+
// unconditional `NSWindowSharingNone` the native stealth module applies to
281+
// exactly these three windows. Force it on for them regardless of `enable`.
282+
const overlayChrome = [this.overlayWindow, this.pillWindow, this.toggleWindow];
283+
overlayChrome.forEach((win) => {
284+
if (win && !win.isDestroyed()) {
285+
win.setContentProtection(true);
286+
}
287+
});
288+
// The launcher and popover catcher are not meeting chrome; they follow the
289+
// undetectable-mode toggle (the launcher is the main window shown outside a
290+
// meeting, and the native module deliberately does NOT force-hide it).
291+
const undetectableFollowers = [this.launcherWindow, this.popoverCatcher];
292+
undetectableFollowers.forEach((win) => {
280293
if (win && !win.isDestroyed()) {
281294
win.setContentProtection(enable);
282295
}
@@ -788,7 +801,12 @@ export class WindowHelper {
788801
// "still steals focus" reports — the bundle is only read at launch).
789802
console.log('[WindowHelper] Windows no-activate policy applied to overlay');
790803
}
791-
this.overlayWindow.setContentProtection(this.contentProtection);
804+
// Always protected: the overlay is a ghost surface that must never show in a
805+
// screen capture, regardless of undetectable/dock mode (see
806+
// applyContentProtection). This mirrors the native module's unconditional
807+
// NSWindowSharingNone and closes the leak on builds where that native binary
808+
// is unavailable (e.g. an Intel prebuild mismatch).
809+
this.overlayWindow.setContentProtection(true);
792810
// Apply the current mouse-interaction policy to the NEW window. Without
793811
// this, a window (re)created while stealth passthrough is ON would start
794812
// fully interactive — silently breaking passthrough until the next toggle.
@@ -1593,7 +1611,10 @@ export class WindowHelper {
15931611
[this.toggleWindow, 'overlay-toggle'],
15941612
];
15951613
for (const [win, name] of auxPairs) {
1596-
win.setContentProtection(this.contentProtection);
1614+
// Always protected, like the overlay body — the pill/toggle are the
1615+
// on-screen meeting chrome and must never leak into a shared screen
1616+
// regardless of undetectable/dock mode (see applyContentProtection).
1617+
win.setContentProtection(true);
15971618
if (process.platform === 'darwin') {
15981619
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
15991620
win.setHiddenInMissionControl(true);
@@ -2243,7 +2264,11 @@ export class WindowHelper {
22432264
});
22442265

22452266
// Restore opacity before showing (it may have been zeroed by hideMainWindow).
2246-
if (process.platform === 'win32' && this.contentProtection) {
2267+
// The overlay is ALWAYS content-protected (see applyContentProtection), so on
2268+
// Windows the opacity shield must run on every overlay show — not only in
2269+
// undetectable mode — or the first frame leaks before DWM applies the
2270+
// capture-exclusion flag.
2271+
if (process.platform === 'win32') {
22472272
// Opacity Shield: Show at 0 opacity first to prevent frame leak.
22482273
// The aux windows (pill/toggle) show via the overlay's 'show' event,
22492274
// so shield them the same way — they carry the same on-screen chrome.
@@ -2273,20 +2298,18 @@ export class WindowHelper {
22732298
}
22742299
}, 60);
22752300
} else {
2301+
// macOS / Linux path (Windows always takes the shielded branch above).
22762302
// Restore opacity (may have been zeroed pre-screenshot by hideMainWindow)
22772303
this.overlayWindow.setOpacity(1);
22782304
this.pillWindow?.setOpacity(1);
22792305
this.toggleWindow?.setOpacity(1);
2280-
this.overlayWindow.setContentProtection(this.contentProtection);
2281-
// Re-assert z-order BEFORE show on Windows — DWM processes setAlwaysOnTop
2282-
// synchronously, so calling it before show() ensures the window lands at the
2283-
// correct z-level on first paint. Calling it after focus() would leave a brief
2284-
// window where the HWND is focused at the wrong z-level (issue #136).
2285-
// Skipped on macOS — calling setAlwaysOnTop triggers [NSApp activate] which
2286-
// steals focus from Zoom/browser even when showInactive() was used.
2287-
if (process.platform === 'win32') {
2288-
this.overlayWindow.setAlwaysOnTop(true, 'screen-saver');
2289-
}
2306+
// Always protected — the overlay must never appear in a screen capture,
2307+
// regardless of undetectable/dock mode (see applyContentProtection).
2308+
this.overlayWindow.setContentProtection(true);
2309+
// No setAlwaysOnTop here: this branch is macOS/Linux only (Windows always
2310+
// takes the shielded branch above, which re-asserts z-order itself). On
2311+
// macOS calling setAlwaysOnTop would trigger [NSApp activate] and steal
2312+
// focus from Zoom/browser even when showInactive() was used.
22902313
if (inactive) this.overlayWindow.showInactive();
22912314
else this.overlayWindow.show();
22922315
// Same synchronous block as the body's show (see the win32 branch) so
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
// Regression test for: the meeting overlay leaking into screen captures.
2+
//
3+
// Bug (issue: "Natively is visible on Google Meet calls"): the overlay chrome
4+
// (overlay body + pill + toggle) is a ghost surface that must NEVER appear in a
5+
// screen capture — that is the app's core promise, and the native stealth module
6+
// force-applies NSWindowSharingNone to exactly those three windows regardless of
7+
// mode. But the JS side coupled the overlay's screen-capture invisibility to
8+
// `this.contentProtection`, which only tracks *undetectable/dock* mode. With
9+
// undetectable mode off (its default), every overlay show called
10+
// `setContentProtection(false)`, flipping sharingType back to
11+
// NSWindowSharingReadOnly and re-exposing the overlay — overriding the native
12+
// module and leaking the overlay onto the shared screen.
13+
//
14+
// Fix: the overlay chrome is ALWAYS content-protected, independent of
15+
// undetectable mode. `applyContentProtection` forces it on for the overlay/pill/
16+
// toggle group and only lets the launcher/popover follow `enable`; the creation
17+
// and show sites pass `true` rather than `this.contentProtection`.
18+
//
19+
// Strategy: source-level static check on WindowHelper.ts. The helper instantiates
20+
// BrowserWindow on import and pulls in Electron main-process APIs, so it cannot be
21+
// cleanly unit-tested in isolation (same approach as
22+
// SetContentProtectionDedupe.test.mjs). We parse the applyContentProtection body
23+
// and assert the actual MAPPING — each overlay-chrome window is iterated by a
24+
// forEach that applies `setContentProtection(true)`, while the launcher follows
25+
// `enable` — so a refactor that drops one window into the mode-dependent group
26+
// (or flips its argument to `enable`/`false`) fails here instead of silently
27+
// re-introducing the screen-capture leak.
28+
//
29+
// IMPORTANT — why the positive assertions are scoped to a specific method body
30+
// rather than run against the whole file: `applyContentProtection` itself now
31+
// contains the literal text `win.setContentProtection(true)`, and the overlay
32+
// show path contains `this.overlayWindow.setContentProtection(true)`. A
33+
// whole-source regex for either string therefore passes even when the *creation*
34+
// site it claims to guard has been reverted to `this.contentProtection` (verified
35+
// with a mutation probe: reverting both creation sites still gave 8/8 green).
36+
// Each positive check below is anchored to the body of the method that owns the
37+
// site, so a revert there actually fails the test.
38+
39+
import { test } from 'node:test';
40+
import assert from 'node:assert/strict';
41+
import { readFileSync } from 'node:fs';
42+
import path from 'node:path';
43+
import { fileURLToPath } from 'node:url';
44+
45+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
46+
const windowHelperPath = path.resolve(__dirname, '../../../electron/WindowHelper.ts');
47+
const source = readFileSync(windowHelperPath, 'utf8');
48+
49+
const OVERLAY_CHROME = ['this.overlayWindow', 'this.pillWindow', 'this.toggleWindow'];
50+
51+
/**
52+
* Extract a method body via brace-balancing from the first match of `sigRe`.
53+
* Mirrors the extractor in SetContentProtectionDedupe.test.mjs.
54+
*/
55+
function extractMethodBody(src, sigRe, label) {
56+
const m = sigRe.exec(src);
57+
assert.ok(m, `could not locate ${label} in WindowHelper`);
58+
let i = m.index + m[0].length;
59+
let depth = 1;
60+
const start = i;
61+
while (i < src.length && depth > 0) {
62+
const ch = src[i];
63+
if (ch === '{') depth++;
64+
else if (ch === '}') depth--;
65+
i++;
66+
}
67+
assert.equal(depth, 0, `unbalanced braces while extracting ${label}`);
68+
return src.slice(start, i - 1);
69+
}
70+
71+
/**
72+
* Parse applyContentProtection into { members, arg } groups: each window-array
73+
* literal paired with the argument the immediately-following
74+
* `setContentProtection(...)` call applies to it. This binds each window to the
75+
* protection value it actually receives, rather than checking for the array and
76+
* the literal `true` independently (which a per-window regression could slip
77+
* past).
78+
*/
79+
function parseProtectionGroups(body) {
80+
const groups = [];
81+
const arrayRe = /\[([^\][]*)\]/g; // window arrays contain no nested brackets
82+
let m;
83+
while ((m = arrayRe.exec(body)) !== null) {
84+
const members = m[1].split(',').map((s) => s.trim()).filter(Boolean);
85+
if (!members.some((x) => x.startsWith('this.'))) continue; // not a window array
86+
const after = body.slice(m.index + m[0].length);
87+
const callMatch = /\.\s*setContentProtection\s*\(\s*([A-Za-z0-9_.]+)\s*\)/.exec(after);
88+
if (!callMatch) continue;
89+
groups.push({ members, arg: callMatch[1] });
90+
}
91+
return groups;
92+
}
93+
94+
const body = extractMethodBody(
95+
source,
96+
/(?:public\s+|private\s+|protected\s+)?applyContentProtection\s*\(\s*enable\s*:\s*boolean\s*\)\s*:\s*void\s*\{/,
97+
'applyContentProtection',
98+
);
99+
const groups = parseProtectionGroups(body);
100+
101+
// The overlay body is created here (createWindow builds the launcher AND the
102+
// overlay); the pill/toggle are created in createOverlayAuxWindows. Scoping the
103+
// creation assertions to these bodies is what makes them bite — see the note at
104+
// the top of this file.
105+
const createWindowBody = extractMethodBody(
106+
source,
107+
/(?:public\s+|private\s+|protected\s+)?createWindow\s*\(\s*\)\s*:\s*void\s*\{/,
108+
'createWindow',
109+
);
110+
const createAuxBody = extractMethodBody(
111+
source,
112+
/(?:public\s+|private\s+|protected\s+)?createOverlayAuxWindows\s*\(\s*startUrl\s*:\s*string\s*\)\s*:\s*void\s*\{/,
113+
'createOverlayAuxWindows',
114+
);
115+
116+
test('applyContentProtection maps at least two window groups (chrome + followers)', () => {
117+
assert.ok(
118+
groups.length >= 2,
119+
`BUG: applyContentProtection no longer splits windows into an always-protected ` +
120+
`overlay-chrome group and a mode-dependent follower group (found ${groups.length} ` +
121+
`window group(s)). Parsed groups: ${JSON.stringify(groups)}`,
122+
);
123+
});
124+
125+
for (const win of OVERLAY_CHROME) {
126+
test(`applyContentProtection protects ${win} unconditionally (setContentProtection(true))`, () => {
127+
const owning = groups.filter((g) => g.members.includes(win));
128+
assert.ok(
129+
owning.length > 0,
130+
`BUG: ${win} is not handled in applyContentProtection. It is on-screen meeting ` +
131+
`chrome and must be force-protected there, independent of undetectable mode.`,
132+
);
133+
for (const g of owning) {
134+
assert.equal(
135+
g.arg,
136+
'true',
137+
`BUG: ${win} receives \`setContentProtection(${g.arg})\` in applyContentProtection, ` +
138+
`not \`true\`. The overlay chrome must be protected unconditionally — coupling it to ` +
139+
`\`enable\` (undetectable mode) re-exposes the overlay in screen shares whenever ` +
140+
`undetectable mode is off (its default).`,
141+
);
142+
}
143+
});
144+
}
145+
146+
test('applyContentProtection keeps the launcher on the undetectable-mode toggle (enable)', () => {
147+
// Sanity that the split is real: the launcher is NOT meeting chrome and must
148+
// still follow the toggle, so we are not just blanket-forcing everything true.
149+
const owning = groups.filter((g) => g.members.includes('this.launcherWindow'));
150+
assert.ok(
151+
owning.length > 0,
152+
'BUG: applyContentProtection no longer references this.launcherWindow — the launcher ' +
153+
'must follow undetectable mode.',
154+
);
155+
for (const g of owning) {
156+
assert.equal(
157+
g.arg,
158+
'enable',
159+
`BUG: the launcher receives \`setContentProtection(${g.arg})\` instead of following ` +
160+
`\`enable\`. It is the main window shown outside a meeting and the native module ` +
161+
`deliberately does not force-hide it.`,
162+
);
163+
}
164+
});
165+
166+
test('the overlay body is never protected via this.contentProtection', () => {
167+
// The exact leak: creating/showing the overlay with the undetectable-mode
168+
// value flips sharingType back to ReadOnly in normal mode. Whole-source on
169+
// purpose — no site anywhere may reintroduce it.
170+
assert.ok(
171+
!/this\.overlayWindow\.setContentProtection\s*\(\s*this\.contentProtection\s*\)/.test(source),
172+
`BUG: WindowHelper still calls ` +
173+
`\`this.overlayWindow.setContentProtection(this.contentProtection)\`. The overlay must ` +
174+
`always be protected (pass \`true\`); gating it on undetectable mode leaks the overlay ` +
175+
`onto shared screens.`,
176+
);
177+
});
178+
179+
test('the overlay body is CREATED with content protection forced on', () => {
180+
// Scoped to createWindow so the show-path call sites cannot satisfy it.
181+
assert.ok(
182+
/this\.overlayWindow\.setContentProtection\s*\(\s*true\s*\)/.test(createWindowBody),
183+
`BUG: the overlay window is not created with \`setContentProtection(true)\` in ` +
184+
`createWindow. It must be protected from the first frame, independent of undetectable ` +
185+
`mode — a show-site call is too late and does not cover a window created while hidden.`,
186+
);
187+
});
188+
189+
test('the overlay body is SHOWN with content protection forced on', () => {
190+
// switchToOverlay re-asserts protection on both the Windows (opacity-shield)
191+
// and macOS/Linux branches. Scoped so the creation site cannot satisfy it.
192+
const switchToOverlayBody = extractMethodBody(
193+
source,
194+
/(?:public\s+|private\s+|protected\s+)?switchToOverlay\s*\(\s*inactive\s*\??\s*:\s*boolean[^)]*\)\s*:\s*void\s*\{/,
195+
'switchToOverlay',
196+
);
197+
const forced = switchToOverlayBody.match(
198+
/this\.overlayWindow\.setContentProtection\s*\(\s*true\s*\)/g,
199+
);
200+
assert.equal(
201+
forced?.length,
202+
2,
203+
`BUG: switchToOverlay has ${forced?.length ?? 0} \`setContentProtection(true)\` call(s) ` +
204+
`on the overlay, expected 2 (the win32 opacity-shield branch and the macOS/Linux branch). ` +
205+
`Both show paths must force protection on before the first painted frame.`,
206+
);
207+
});
208+
209+
test('the pill/toggle aux windows are CREATED with a literal true, not this.contentProtection', () => {
210+
// Scoped to createOverlayAuxWindows so applyContentProtection's own
211+
// `win.setContentProtection(true)` cannot satisfy it.
212+
assert.ok(
213+
/win\.setContentProtection\s*\(\s*true\s*\)/.test(createAuxBody),
214+
`BUG: the overlay aux windows (pill/toggle) are not protected with ` +
215+
`\`win.setContentProtection(true)\` at creation in createOverlayAuxWindows. They are ` +
216+
`on-screen meeting chrome and must never leak into a shared screen.`,
217+
);
218+
assert.ok(
219+
!/win\.setContentProtection\s*\(\s*this\.contentProtection\s*\)/.test(source),
220+
`BUG: the overlay aux windows are still protected via ` +
221+
`\`win.setContentProtection(this.contentProtection)\`, which re-exposes them in normal mode.`,
222+
);
223+
});

0 commit comments

Comments
 (0)