|
| 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