diff --git a/apps/desktop/main/hybrid-overlay.ts b/apps/desktop/main/hybrid-overlay.ts index 8e0b13ea..2f7910f1 100644 --- a/apps/desktop/main/hybrid-overlay.ts +++ b/apps/desktop/main/hybrid-overlay.ts @@ -110,6 +110,7 @@ import { shadowKernelWindowContentOrderable, shadowKernelAppActivated, shadowKernelAppResigned, + shadowKernelWindowAcceptsInputChanged, getShadowKernelExecutor, } from './window-kernel-shadow.js'; import { getFaviconForUrl } from './datastore.js'; @@ -488,6 +489,10 @@ function wireClickCapture(): void { } else { overlayWin.setIgnoreMouseEvents(true, { forward: true }); } + // Machine click-through mirror (window-kernel.ts + // WindowAcceptsInputChanged) — capture:true means the overlay now takes + // input, capture:false means it is click-through again. + if (overlayWmId != null) shadowKernelWindowAcceptsInputChanged(overlayWmId, capture, getWindowStateSnapshot()); }); } @@ -524,6 +529,9 @@ function wireAppActiveGate(): void { } catch { /* setIgnoreMouseEvents unsupported in some envs */ } + // Machine click-through mirror (window-kernel.ts + // WindowAcceptsInputChanged) — forced click-through on this edge. + if (overlayWmId != null) shadowKernelWindowAcceptsInputChanged(overlayWmId, false, getWindowStateSnapshot()); // The WINDOW half of this edge is the reducer's (§5 step 9): `APP_RESIGNED` // pushes `HIDE_WINDOW` for every attached overlay (peek 88797f2d / R6), so // the chrome leaves the screen while Peek is backgrounded. The old @@ -542,6 +550,10 @@ function wireAppActiveGate(): void { } catch { /* unsupported */ } + // Machine click-through mirror (window-kernel.ts + // WindowAcceptsInputChanged) — restored to default click-through, still + // not capturing. + if (overlayWmId != null) shadowKernelWindowAcceptsInputChanged(overlayWmId, false, getWindowStateSnapshot()); // The re-reveal is the reducer's: `APP_ACTIVATED` runs `applyOverlayFollow` // for every attached overlay, restoring its z-pin and re-showing it over the // host it is still attached to (the attachment survived the resign, so no diff --git a/apps/desktop/main/izui-roles.test.ts b/apps/desktop/main/izui-roles.test.ts index cf6482c6..2710fe86 100644 --- a/apps/desktop/main/izui-roles.test.ts +++ b/apps/desktop/main/izui-roles.test.ts @@ -635,8 +635,8 @@ describe('deriveWindowSurface — real construction sites', () => { ); }); - it('the windows switcher (features/windows/background.js openWindowsView): role overlay, alwaysOnTop, and it TAKES clicks', () => { - // Same role and same alwaysOnTop as the space border, opposite + it('the windows switcher (features/windows/background.js openWindowsView): role switcher, alwaysOnTop, and it TAKES clicks', () => { + // Same alwaysOnTop as the space border, different role, opposite // acceptsInput — the user clicks windows in it. This is why acceptsInput // cannot key on role. assert.deepStrictEqual( diff --git a/apps/desktop/main/izui-roles.ts b/apps/desktop/main/izui-roles.ts index cbc14114..9c83b9e0 100644 --- a/apps/desktop/main/izui-roles.ts +++ b/apps/desktop/main/izui-roles.ts @@ -561,11 +561,14 @@ export function deriveCanBecomeKey(facts: Pick) * click-through is a `setIgnoreMouseEvents(true, …)` call, and nothing else in * the open params implies one. * - * ROLE IS NOT A PROXY — the two production role-`'overlay'` windows sit on - * opposite sides of this. `features/windows/background.js openWindowsView` - * opens a full-screen switcher the user clicks windows in; it must keep mouse - * input. `features/spaces/background.js` opens a screen-edge border the pointer - * must pass straight through. Same role, same `alwaysOnTop`, opposite answers. + * ROLE IS NOT A PROXY — the windows switcher and the space border sit on + * opposite sides of this despite both floating (`alwaysOnTop: true`). + * `features/windows/background.js openWindowsView` opens a full-screen + * switcher (`role: 'switcher'`) the user clicks windows in; it must keep + * mouse input. `features/spaces/background.js` opens a screen-edge border + * (`role: 'overlay'`) the pointer must pass straight through. Same + * `alwaysOnTop`, different role, opposite answers — neither fact predicts + * the other. * * THIS IS THE INITIAL VALUE ONLY. The shared chrome overlay * (`hybrid-overlay.ts ensureOverlay`) is click-through from birth — its diff --git a/apps/desktop/main/tile-ipc.ts b/apps/desktop/main/tile-ipc.ts index e923595d..1189c32f 100644 --- a/apps/desktop/main/tile-ipc.ts +++ b/apps/desktop/main/tile-ipc.ts @@ -142,6 +142,7 @@ import { shadowKernelSetDesktopMembership, shadowKernelShowRequested, shadowKernelHideRequested, + shadowKernelWindowAcceptsInputChanged, } from './window-kernel-shadow.js'; import { registerGlobalShortcut, @@ -3248,6 +3249,14 @@ export function registerTileIpcHandlers(): void { } const forward = args.options?.forward === true; win.setIgnoreMouseEvents(args.ignore === true, forward ? { forward: true } : undefined); + // Machine click-through mirror (window-kernel.ts + // WindowAcceptsInputChanged) — fed from here because this IS the + // chokepoint that causes the change; there is no native echo to + // observe (Electron has no getter for `setIgnoreMouseEvents`). + const machineId = getWindowIdFor(win); + if (machineId != null) { + shadowKernelWindowAcceptsInputChanged(machineId, args.ignore !== true, getWindowStateSnapshot()); + } return { success: true }; } catch (err) { const message = err instanceof Error ? err.message : String(err); diff --git a/apps/desktop/main/window-kernel-properties.test.ts b/apps/desktop/main/window-kernel-properties.test.ts index f356f71f..53f5d486 100644 --- a/apps/desktop/main/window-kernel-properties.test.ts +++ b/apps/desktop/main/window-kernel-properties.test.ts @@ -407,8 +407,17 @@ function randomLogItem(rng: Rng, state: State): LogItem { // for ids that were never registered, for `browser`-kind windows that // would never produce one in production, and in nonsense orders (held // twice, lost before ever held). + // + // SHARES ITS SLOT with the click-through mirror (`WindowAcceptsInputChanged`) + // via a sub-roll, same shape as the `cause` sub-roll on variant 4 — both + // are mechanical, effect-free mirror writes. Growing the switch's own + // modulus for a second one would re-derive every case's probability + // boundary and reshuffle every already-pinned seed's trajectory (the same + // measured cost that motivated case 32's four-way sub-roll below). case 31: - return { type: 'ContentFirstResponderChanged', id, held: randomBool(rng) }; + return rng() < 0.5 + ? { type: 'ContentFirstResponderChanged', id, held: randomBool(rng) } + : { type: 'WindowAcceptsInputChanged', id, acceptsInput: randomBool(rng) }; // Teardown commands (§0.1.5) — through `decide`, same stale-expectedVersion // treatment as the other command variants above. `id` is drawn from the raw // pool exactly like everything else here, so the majority of these name a @@ -660,6 +669,12 @@ function checkInvariants(state: State, prev?: State): string[] { violations.push(`malformed contentFocused for ${record.id}: ${JSON.stringify(record.contentFocused)}`); } + // Click-through mirror (this slice): required, never absent — a shape + // check only, the same as `layer` above. + if (typeof record.acceptsInput !== 'boolean') { + violations.push(`malformed acceptsInput for ${record.id}: ${JSON.stringify(record.acceptsInput)}`); + } + // `pendingHidden` (this slice): absent or boolean — a shape check only. // Deliberately NOT a "never decays back to absent" clause the way // `contentFocused`/`keepLive`/`overlayFrame`/`bounds` get below: clearing @@ -1477,6 +1492,10 @@ describe('window-kernel: the re-registration merge preserves every lived fact', { type: 'DesktopMembershipSet', id, membership: 'anchor' }, { type: 'OverlayAttached', overlayId: id, hostId: host, frame: { x: 10, y: 20, width: 30, height: 40 } }, { type: 'ContentFirstResponderChanged', id, held: false }, + // `register()` above declares `acceptsInput: true`; flipping it false + // here proves the merge preserves the LIVED value rather than the one + // the event happens to share with registration. + { type: 'WindowAcceptsInputChanged', id, acceptsInput: false }, { type: 'FullscreenTransitionStarted', id, entering: true, preBounds: { x: 9, y: 9, width: 9, height: 9 } }, // Sets `pendingHidden` without removing the record from `order` — a // teardown REQUEST, not the confirming `WindowClosed`. Without this the @@ -1530,6 +1549,7 @@ describe('window-kernel: the re-registration merge preserves every lived fact', assert.strictEqual(record.keepLive, true); assert.strictEqual(record.parentId, host); assert.strictEqual(record.pendingHidden, true); + assert.strictEqual(record.acceptsInput, false); }); it('the hiddenByAppBlur mark survives a re-registration mid-sweep', () => { @@ -1551,7 +1571,7 @@ describe('window-kernel: the re-registration merge preserves every lived fact', // than merely present. const before = fold(livedLog()); const record = before.order.find((w) => w.id === id)!; - const strip = (field: 'contentFocused' | 'escapeMode' | 'bounds' | 'pendingHidden'): State => { + const strip = (field: 'contentFocused' | 'escapeMode' | 'bounds' | 'pendingHidden' | 'acceptsInput'): State => { const mutilated: Record = { ...record }; delete mutilated[field]; return { @@ -1570,6 +1590,12 @@ describe('window-kernel: the re-registration merge preserves every lived fact', assert.deepStrictEqual(checkInvariants(strip('bounds'), before), [ `bounds decayed back to absent for ${id}`, ]); + // `acceptsInput` is required, so stripping it produces a shape violation + // rather than a "decayed back to absent" one — the field can never be + // legitimately absent, only malformed. + assert.deepStrictEqual(checkInvariants(strip('acceptsInput'), before), [ + `malformed acceptsInput for ${id}: undefined`, + ]); // `pendingHidden` is the deliberate exception (see `checkInvariants`' own // comment): clearing it is legitimate kernel behavior, not a merge bug, // so stripping it back to absent must NOT be named as a violation the way diff --git a/apps/desktop/main/window-kernel-shadow.test.ts b/apps/desktop/main/window-kernel-shadow.test.ts index 2854c805..c086ba34 100644 --- a/apps/desktop/main/window-kernel-shadow.test.ts +++ b/apps/desktop/main/window-kernel-shadow.test.ts @@ -43,6 +43,7 @@ import { shadowKernelMaximize, shadowKernelUnmaximize, shadowKernelSetDesktopMembership, + shadowKernelWindowAcceptsInputChanged, } from './window-kernel-shadow.js'; import { WindowKernelExecutor } from './window-kernel-executor.js'; import { BASE_LAYER, isContentWindow, topmost, topmostBaseLayerPageHost, type WindowId } from './window-kernel.js'; @@ -1092,6 +1093,66 @@ describe('window-kernel-shadow: content first responder feed', () => { }); }); +describe('window-kernel-shadow: window accepts-input feed', () => { + beforeEach(() => { + _resetShadowKernelForTests(); + }); + + it('a click-through report reaches the shadow kernel as acceptsInput: false, naming the window', () => { + const id = wid(160); + const oldSnapshot = fakeOldMachine({ front: null, stackOrder: [], ids: [] }); + shadowKernelRegisterWindow( + { + id, + kind: 'browser', + layer: BASE_LAYER, + canBecomeKey: true, + acceptsInput: true, + class: 'primary', + }, + oldSnapshot, + ); + + shadowKernelWindowAcceptsInputChanged(id, false, oldSnapshot); + + const record = getShadowKernelExecutor() + .getState() + .order.find((w) => w.id === id); + assert.strictEqual(record?.acceptsInput, false); + }); + + it('a capturing report flips it back to true', () => { + const id = wid(161); + const oldSnapshot = fakeOldMachine({ front: null, stackOrder: [], ids: [] }); + shadowKernelRegisterWindow( + { + id, + kind: 'browser', + layer: BASE_LAYER, + canBecomeKey: true, + acceptsInput: true, + class: 'primary', + }, + oldSnapshot, + ); + shadowKernelWindowAcceptsInputChanged(id, false, oldSnapshot); + const record = () => getShadowKernelExecutor().getState().order.find((w) => w.id === id); + assert.strictEqual(record()?.acceptsInput, false, 'precondition: click-through'); + + shadowKernelWindowAcceptsInputChanged(id, true, oldSnapshot); + + assert.strictEqual(record()?.acceptsInput, true); + }); + + it('never throws across an accepts-input feed naming a window with no record', () => { + const danglingId = wid(162); // never registered with the shadow kernel + const oldSnapshot = fakeOldMachine({ front: null, stackOrder: [], ids: [] }); + + assert.doesNotThrow(() => shadowKernelWindowAcceptsInputChanged(danglingId, false, oldSnapshot)); + assert.strictEqual(getShadowKernelExecutor().getState().order.length, 0, 'a dangling id never joins order'); + }); +}); + describe('window-kernel-shadow: cold-start focus latch feed', () => { beforeEach(() => { _resetShadowKernelForTests(); diff --git a/apps/desktop/main/window-kernel-shadow.ts b/apps/desktop/main/window-kernel-shadow.ts index caf4fd7f..5dc95c31 100644 --- a/apps/desktop/main/window-kernel-shadow.ts +++ b/apps/desktop/main/window-kernel-shadow.ts @@ -755,6 +755,19 @@ export function shadowKernelContentFirstResponderChanged(id: WindowId, held: boo compareAfterFeed(oldSnapshot); } +/** + * Translate a window's post-registration click-through change into the + * kernel's `WindowAcceptsInputChanged` event — a mechanical mirror write + * (window-kernel.ts's evolve arm), the sole post-registration writer of + * `acceptsInput`. `acceptsInput` is stored as reported: the kernel never + * infers click-through state, only records what the causing site already did + * (see the event's own doc for why this is fed rather than observed). + */ +export function shadowKernelWindowAcceptsInputChanged(id: WindowId, acceptsInput: boolean, oldSnapshot: StateSnapshot): void { + shadowDispatch({ type: 'WindowAcceptsInputChanged', id, acceptsInput }); + compareAfterFeed(oldSnapshot); +} + // ───────────────────────────────────────────────────────────────────────────── // Cold-start focus latch — the tenth real input this step feeds the kernel. // Translated at the two production sites that dispatch the old machine's diff --git a/apps/desktop/main/window-kernel.test.ts b/apps/desktop/main/window-kernel.test.ts index 8e177b07..f2445e1f 100644 --- a/apps/desktop/main/window-kernel.test.ts +++ b/apps/desktop/main/window-kernel.test.ts @@ -299,12 +299,17 @@ describe('window-kernel: re-registration merges in place', () => { assert.strictEqual(merged.kind, 'browser'); assert.strictEqual(merged.layer, 2); assert.strictEqual(merged.canBecomeKey, false); - assert.strictEqual(merged.acceptsInput, false); assert.strictEqual(merged.class, 'hud'); assert.strictEqual(merged.parentId, wid(2)); // Lived facts are preserved, not reset by the re-registration. assert.strictEqual(merged.visible, true); assert.strictEqual(merged.openSeq, openSeqBefore); + // `acceptsInput` is a LIVED fact (§ WindowRecord.acceptsInput's doc), not + // an intrinsic: the event's `acceptsInput: false` above must NOT + // overwrite the `true` the original registration lived with — a + // re-registration recomputing surface facts fresh from open params must + // not undo a click-through change `WindowAcceptsInputChanged` reported. + assert.strictEqual(merged.acceptsInput, true); }); it('re-registration overwrites a named role and keeps the existing one when the event names none', () => { diff --git a/apps/desktop/main/window-kernel.ts b/apps/desktop/main/window-kernel.ts index b11e9d72..acdd74dc 100644 --- a/apps/desktop/main/window-kernel.ts +++ b/apps/desktop/main/window-kernel.ts @@ -134,7 +134,12 @@ export interface WindowRecord { layer: number; /** Electron `focusable` (§0.1.4: "a HUD never holds key" needs no special case). */ canBecomeKey: boolean; - /** false = click-through (`setIgnoreMouseEvents`). */ + /** + * false = click-through (`setIgnoreMouseEvents`). Set at registration; a + * window whose click-through changes afterward (e.g. the Space border's + * separate IPC round trip) is kept honest by `WindowAcceptsInputChanged`, + * the sole post-registration writer — see that event's doc. + */ acceptsInput: boolean; /** Creation-time shorthand for logs and singleton lookups. Nothing branches on it. */ @@ -764,6 +769,31 @@ export type Event = * a request OF it. */ | { type: 'ContentFirstResponderChanged'; id: WindowId; held: boolean } + /** + * A window's click-through state changed AFTER registration — the report + * that closes the gap `izui-roles.ts deriveAcceptsInput` names in its own + * doc: "A window whose click-through changes after registration needs an + * event, not a re-derivation." The Space border + * (`features/spaces/background.js showBorder`) is the case that gap left + * open — its `api.window.setIgnoreMouseEvents(...)` is a separate IPC round + * trip issued after `api.window.open` resolves, so the OS is already + * click-through by the time this arrives. Drives no effect: it is a report + * ABOUT the OS, not a request OF it, same as `ContentFirstResponderChanged` + * above. A report naming an unregistered window is a no-op, like every + * other dangling-id event here. + * + * FED FROM THE SITES THAT CAUSE THE CHANGE, not a backend observation — + * Electron has no getter for `setIgnoreMouseEvents` (the same reason + * `hybrid-overlay.ts` keeps its own `lastCaptureState` mirror rather than + * asking the OS), so this arrives from `tile-ipc.ts`'s + * `tile:window:set-ignore-mouse` handler and `hybrid-overlay.ts`'s + * `wireClickCapture`/`wireAppActiveGate`, at the moment each makes the + * change, rather than from a native echo. `WindowContentOrderable` above is + * the existing precedent for that compromise: the OS has no "is this window + * orderable" query either, so that fact too is fed from the site that + * observes it happen rather than polled. + */ + | { type: 'WindowAcceptsInputChanged'; id: WindowId; acceptsInput: boolean } /** * `decide`'s translation of a `SetFullscreen` command that starts a FRESH * transition (the target was settled — `normal`/`fullscreen`) into a fact @@ -1448,7 +1478,16 @@ export function evolve(state: State, ev: Event): State { kind: ev.kind, layer: ev.layer, canBecomeKey: ev.canBecomeKey, - acceptsInput: ev.acceptsInput, + // A LIVED fact (§ WindowRecord.acceptsInput's doc), same shape as + // `fsMode` below — never taken from a re-registration event. + // `ipc.ts` re-registers windows routinely and recomputes + // `deriveWindowSurface(...)` fresh from open params, which for the + // Space border yields `acceptsInput: true` (its click-through is a + // separate IPC call the re-derivation cannot see — see + // `izui-roles.ts deriveAcceptsInput`'s doc); taking the event's + // value here would silently undo the honest fact + // `WindowAcceptsInputChanged` reported. + acceptsInput: existing.acceptsInput, class: ev.class, visible: existing.visible, openSeq: existing.openSeq, @@ -2065,6 +2104,18 @@ export function evolve(state: State, ev: Event): State { }; } + case 'WindowAcceptsInputChanged': { + // Mechanical mirror write, total and guard-free: an unregistered id is + // a harmless no-op, and `acceptsInput` is stored as reported — same + // shape as `ContentFirstResponderChanged` above, and the sole + // post-registration writer of the field (see `WindowRecord.acceptsInput`'s doc). + return { + ...state, + order: state.order.map((w) => (w.id === ev.id ? { ...w, acceptsInput: ev.acceptsInput } : w)), + version: state.version + 1, + }; + } + case 'FullscreenTransitionStarted': { // `decide` already ran the in-flight guard and resolved `preBounds` — // this is a mechanical write, no per-mode branching. diff --git a/scripts/check-platform-leak.mjs b/scripts/check-platform-leak.mjs index bff38646..a9f60cad 100644 --- a/scripts/check-platform-leak.mjs +++ b/scripts/check-platform-leak.mjs @@ -92,6 +92,7 @@ const FORBIDDEN = [ 'setFullScreen', 'showInactive', 'orderFront', + 'setIgnoreMouseEvents', 'NSPanel', 'NSWorkspace', 'LSUIElement',