Skip to content

Commit 73dda11

Browse files
Hardening v2.7claude
andcommitted
fix(stealth): mark the opacity shield spent when it fires
Both arm sites assigned this.opacityTimeout and neither callback nulled it, so after any win32 overlay/launcher show the field held an already-fired Timeout forever. cancelOpacityShield()'s "nothing pending" early return therefore never fired and every later minimize/close ran a restore pass — harmless today (the shield and hideMainWindow are the only things that zero those four windows, and hideMainWindow hides them first, so the isVisible() guard skips them) but it made the guard, and the test asserting it, vacuous. Also pins the platform gate on the chrome re-push: it may skip win32, but it must never exclude darwin. reassertContentProtection() routes through applyContentProtection after app.dock.hide()/show(), and that flip makes WindowServer silently reset sharingType — the re-push is the only thing that restores NSWindowSharingNone. Narrowing the gate the other way would un-protect the overlay after every dock toggle with nothing to catch it. Renames the per-window assertion, which said "unconditionally" while the literal now sits inside a platform gate. The unconditional part is covered by the CREATED/SHOWN tests. Mutation probes confirmed failing: either callback's null removed; the gate flipped to `!== 'darwin'`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Si93L7isjy1GFkqzjb7gJy
1 parent 86b5ad9 commit 73dda11

3 files changed

Lines changed: 54 additions & 2 deletions

File tree

electron/WindowHelper.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2309,6 +2309,11 @@ export class WindowHelper {
23092309

23102310
if (this.opacityTimeout) clearTimeout(this.opacityTimeout);
23112311
this.opacityTimeout = setTimeout(() => {
2312+
// Mark the shield spent. Without this the field holds an
2313+
// already-fired Timeout forever, so cancelOpacityShield()'s
2314+
// "nothing pending" early return never fires and every later
2315+
// minimize/close runs a pointless restore pass.
2316+
this.opacityTimeout = null;
23122317
if (this.overlayWindow && !this.overlayWindow.isDestroyed()) {
23132318
this.overlayWindow.setOpacity(1);
23142319
this.pillWindow?.setOpacity(1);
@@ -2407,6 +2412,8 @@ export class WindowHelper {
24072412

24082413
if (this.opacityTimeout) clearTimeout(this.opacityTimeout);
24092414
this.opacityTimeout = setTimeout(() => {
2415+
// Mark the shield spent — see the overlay branch above.
2416+
this.opacityTimeout = null;
24102417
if (this.launcherWindow && !this.launcherWindow.isDestroyed()) {
24112418
this.launcherWindow.setOpacity(1);
24122419
if (!inactive) this.launcherWindow.focus();

electron/audio/__tests__/OpacityShieldCancelRestores.test.mjs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,27 @@ for (const [label, body] of [['minimizeWindow', minimizeBody], ['closeWindow', c
134134
});
135135
}
136136

137+
test('the shield timer marks itself spent when it fires', () => {
138+
// Without this the field holds an already-fired Timeout forever, so
139+
// cancelOpacityShield()'s "nothing pending" early return never fires — the
140+
// guard reads as meaningful while every later minimize/close runs a
141+
// pointless restore pass. Nulling here is what makes that guard real.
142+
for (const name of ['switchToOverlay', 'switchToLauncher']) {
143+
const body = extractMethodBody(source, sigFor(name, '[^)]*'), name);
144+
const callback = extractMethodBody(
145+
body,
146+
/this\.opacityTimeout\s*=\s*setTimeout\s*\(\s*\(\s*\)\s*=>\s*\{/,
147+
`${name}'s opacity-shield callback`,
148+
);
149+
assert.ok(
150+
/this\.opacityTimeout\s*=\s*null/.test(callback),
151+
`BUG: ${name}'s opacity-shield callback does not null this.opacityTimeout when it ` +
152+
`fires, leaving a stale non-null Timeout behind. cancelOpacityShield()'s early ` +
153+
`return then never triggers.`,
154+
);
155+
}
156+
});
157+
137158
test('the shield ARM sites still use a bare clearTimeout', () => {
138159
// The arm sites call setOpacity(0) and *then* clear the previous timer.
139160
// Routing them through cancelOpacityShield would immediately un-zero the

electron/audio/__tests__/OverlayAlwaysContentProtected.test.mjs

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ test('applyContentProtection maps at least two window groups (chrome + followers
123123
});
124124

125125
for (const win of OVERLAY_CHROME) {
126-
test(`applyContentProtection protects ${win} unconditionally (setContentProtection(true))`, () => {
126+
test(`applyContentProtection protects ${win} with a literal true, never \`enable\``, () => {
127127
const owning = groups.filter((g) => g.members.includes(win));
128128
assert.ok(
129129
owning.length > 0,
@@ -135,14 +135,38 @@ for (const win of OVERLAY_CHROME) {
135135
g.arg,
136136
'true',
137137
`BUG: ${win} receives \`setContentProtection(${g.arg})\` in applyContentProtection, ` +
138-
`not \`true\`. The overlay chrome must be protected unconditionally — coupling it to ` +
138+
`not \`true\`. The overlay chrome's protection must not depend on the mode — coupling it to ` +
139139
`\`enable\` (undetectable mode) re-exposes the overlay in screen shares whenever ` +
140140
`undetectable mode is off (its default).`,
141141
);
142142
}
143143
});
144144
}
145145

146+
test('the chrome re-push in applyContentProtection still runs on macOS', () => {
147+
// The chrome loop is skipped on win32 (there the write is always
148+
// value-identical, and identical writes cause the DWM affinity churn
149+
// SetContentProtectionDedupe.test.mjs exists to prevent). It must NOT be
150+
// skipped off win32: reassertContentProtection() routes through here after
151+
// app.dock.hide()/show(), and that macOS activation-policy flip makes
152+
// WindowServer re-evaluate each NSWindow and silently reset its sharingType.
153+
// The in-memory value is still correct, so only an unconditional re-push
154+
// restores NSWindowSharingNone. Narrowing this gate to exclude darwin —
155+
// or dropping the chrome from applyContentProtection altogether — silently
156+
// un-protects the overlay after every dock toggle.
157+
const beforeChrome = body.slice(0, body.indexOf('const overlayChrome'));
158+
const gates = beforeChrome.match(/process\.platform\s*[!=]==\s*'[a-z0-9]+'/g) ?? [];
159+
for (const gate of gates) {
160+
assert.equal(
161+
gate,
162+
"process.platform !== 'win32'",
163+
`BUG: the overlay-chrome re-push in applyContentProtection is gated on \`${gate}\`. ` +
164+
`It must run on darwin — reassertContentProtection() relies on it to restore ` +
165+
`NSWindowSharingNone after app.dock.hide()/show() resets sharingType.`,
166+
);
167+
}
168+
});
169+
146170
test('applyContentProtection keeps the launcher on the undetectable-mode toggle (enable)', () => {
147171
// Sanity that the split is real: the launcher is NOT meeting chrome and must
148172
// still follow the toggle, so we are not just blanket-forcing everything true.

0 commit comments

Comments
 (0)