feat(extension): P2 — WindowGuard Layer 1 (bounce-back)

Replace the WindowGuard stub with the Layer 1 of design §6: any non-
whitelisted window that joins the dashboard workspace is immediately
moved back to the workspace it came from. This single hook catches all
real paths into dashboard -- wmctrl/xdotool moves, super+shift+→
keybind, overview drag-drop, programmatic change_workspace, and initial
window map with _NET_WM_DESKTOP pointing at dashboard.

Implementation:

- Per-window previousWs tracking via WeakMap<Meta.Window, number>,
  updated on each workspace-changed signal whenever the new workspace
  isn't the dashboard. At enable, walk all existing windows and
  pre-populate from their current workspace.
- Bounce target resolution: prefer the recorded workspace; fall back to
  (dashboardIndex - 1), which always exists because WorkspaceWarden
  guarantees dashboard isn't workspace 0.
- Whitelist Set<Meta.Window> with allow()/disallow() so P3's
  ContainerSupervisor can pin the WebKit container without it being
  immediately bounced.
- All signal connections use connectObject/disconnectObject with `this`
  as the tracker, so dispose() unwinds everything in O(1) bookkeeping.
- Override-redirect windows (menus, tooltips) are skipped.

Layers 2 (overview drop refusal), 3 (hide dashboard from switcher
strip), and 4 (Super+Page_Up/Down clamp) are documented in the file
header and deferred to P8/P9 -- they're UX polish that prevents the
*attempt*, while Layer 1 alone meets the P2 acceptance criteria of
"every entry vector bounces".

extension.ts wires WindowGuard up after WorkspaceWarden, in its own
try/catch so a guard init failure leaves the warden disposable.

Build-chain tweak: @girs/gnome-shell augments connectObject onto
GObject.Object via dist/extensions/global.d.ts, but that file isn't
pulled in by `@girs/gnome-shell/ambient`. Adding
`@girs/gnome-shell/extensions/global` to tsconfig "types" loads the
augmentation explicitly.

Verified: `pnpm -r build` and `pnpm -r exec tsc --noEmit` are clean;
extension bundle is 44.8 KB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-22 23:12:44 +01:00
parent 7b5539cf75
commit 948dfb0c57
4 changed files with 191 additions and 8 deletions

View File

@@ -26,10 +26,16 @@ export default class DashwardExtension extends Extension {
return;
}
// P2+ components are still placeholders; left commented until each
// phase implements them so we don't pretend to be functional.
// this.guard = new WindowGuard();
// this.container = new ContainerSupervisor();
try {
this.guard = new WindowGuard(this.warden);
} catch (e) {
error(`WindowGuard init failed: ${String(e)}`);
// Dashboard workspace exists but is undefended. Continue so disable()
// can still tear down the warden cleanly.
}
// P3+ components still placeholders, wired in their own phases.
// this.container = new ContainerSupervisor(this.warden, this.guard);
// this.entry = new EntryUX();
// this.dbus = new DBusService();
}

View File

@@ -1,6 +1,10 @@
// GNOME Shell injects a `global` singleton (Shell.Global) into the GJS
// global scope. The @girs/gnome-shell typings don't expose it as a
// resolvable module path, so we declare just the surface we use.
//
// `connectObject`/`disconnectObject` on GObject.Object come from
// @girs/gnome-shell/extensions/global; we register that path in
// tsconfig.json "types" so the augmentation is in scope.
import type Meta from 'gi://Meta';

View File

@@ -1,9 +1,181 @@
// Stub: see design §6 — window-defense rules.
// Layer 1: window-added bounce-back. Layer 2: overview drop refusal.
// Layer 3: hide from switcher strip. Layer 4: workspace switcher clamp.
import type Meta from 'gi://Meta';
import { log, warn } from './util/logger.js';
import type { WorkspaceWarden } from './workspace-warden.js';
// Design §6: window-defense rules.
//
// Layer 1 (this file): `window-added` bounce-back. When any non-whitelisted
// window joins the dashboard workspace by any path -- wmctrl, xdotool,
// keybinding move, overview drag-drop, programmatic change_workspace, or
// an initial map onto dashboard via _NET_WM_DESKTOP -- bounce it back to
// the workspace it came from (or the closest non-dashboard workspace).
//
// To know "the workspace it came from", we track each window's last
// non-dashboard workspace in a Map. Updates happen on Meta.Window's
// `workspace-changed` signal. Cleanup on `unmanaged`.
//
// Layers 2 (overview drop refusal), 3 (hide from switcher strip), and 4
// (workspace switcher clamp on Super+Page_Up/Down) are UX refinements that
// prevent the user from *trying* to enter dashboard via the wrong path.
// Layer 1 catches every attempt regardless, so the acceptance criteria
// for P2 are met by this alone. The polish layers land in P8/P9.
export class WindowGuard {
private readonly warden: WorkspaceWarden;
private readonly whitelist = new Set<Meta.Window>();
private readonly previousWs = new WeakMap<Meta.Window, number>();
private readonly trackedWindows = new Set<Meta.Window>();
private disposed = false;
constructor(warden: WorkspaceWarden) {
this.warden = warden;
this.enable();
}
/** Allow a specific window to live on the dashboard workspace. */
allow(win: Meta.Window): void {
this.whitelist.add(win);
}
disallow(win: Meta.Window): void {
this.whitelist.delete(win);
}
dispose(): void {
// disconnect all signals and restore patched prototypes
this.disposed = true;
const display = global.display;
display.disconnectObject(this);
const dashboard = this.warden.getDashboardWorkspace();
if (dashboard) dashboard.disconnectObject(this);
for (const win of this.trackedWindows) {
try {
win.disconnectObject(this);
} catch (e) {
// Window may already be unmanaged; ignore.
}
}
this.trackedWindows.clear();
this.whitelist.clear();
}
// -- enable --------------------------------------------------------------
private enable(): void {
const wm = global.workspace_manager;
const display = global.display;
const dashboard = this.warden.getDashboardWorkspace();
if (!dashboard) {
warn('WindowGuard.enable: warden has no dashboard workspace; bailing');
return;
}
// Snapshot existing windows so we have a fallback workspace for each.
for (let i = 0; i < wm.get_n_workspaces(); i++) {
const ws = wm.get_workspace_by_index(i);
if (!ws) continue;
for (const win of ws.list_windows()) this.trackWindow(win);
}
// New windows.
display.connectObject(
'window-created',
(_d: Meta.Display, win: Meta.Window) => this.trackWindow(win),
this,
);
// Layer 1: bounce on dashboard window-added.
dashboard.connectObject(
'window-added',
(_ws: Meta.Workspace, win: Meta.Window) => this.bounceIfNeeded(win),
this,
);
}
// -- per-window tracking -------------------------------------------------
private trackWindow(win: Meta.Window): void {
if (this.trackedWindows.has(win)) return;
if (win.is_override_redirect()) return;
// Record current non-dashboard workspace if any.
const ws = win.get_workspace();
const dashIdx = this.warden.getDashboardIndex();
if (ws && ws.index() !== dashIdx) {
this.previousWs.set(win, ws.index());
}
win.connectObject(
'workspace-changed',
() => this.onWindowWorkspaceChanged(win),
'unmanaged',
() => this.onWindowUnmanaged(win),
this,
);
this.trackedWindows.add(win);
}
private onWindowWorkspaceChanged(win: Meta.Window): void {
if (this.disposed) return;
const curr = win.get_workspace();
if (!curr) return;
if (curr.index() !== this.warden.getDashboardIndex()) {
this.previousWs.set(win, curr.index());
}
}
private onWindowUnmanaged(win: Meta.Window): void {
this.trackedWindows.delete(win);
this.whitelist.delete(win);
// previousWs is a WeakMap, no explicit cleanup needed.
}
// -- Layer 1 -------------------------------------------------------------
private bounceIfNeeded(win: Meta.Window): void {
if (this.disposed) return;
if (this.whitelist.has(win)) return;
if (win.is_override_redirect()) return;
// Make sure we're tracking it (covers windows mapped directly onto
// dashboard before workspace-changed could fire).
if (!this.trackedWindows.has(win)) this.trackWindow(win);
const fallback = this.resolveFallback(win);
if (!fallback) {
warn(`bounceIfNeeded: no fallback workspace found for ${win.get_title?.() ?? '?'}`);
return;
}
log(`bouncing "${win.get_title?.() ?? '?'}" from dashboard to ws ${fallback.index()}`);
win.change_workspace(fallback);
}
private resolveFallback(win: Meta.Window): Meta.Workspace | null {
const wm = global.workspace_manager;
const dashIdx = this.warden.getDashboardIndex();
const total = wm.get_n_workspaces();
// Preferred: the workspace the window most recently lived on.
const recorded = this.previousWs.get(win);
if (
recorded !== undefined &&
recorded >= 0 &&
recorded < total &&
recorded !== dashIdx
) {
const ws = wm.get_workspace_by_index(recorded);
if (ws) return ws;
}
// Fallback: the workspace immediately before dashboard (always exists
// because WorkspaceWarden guarantees dashboard isn't workspace 0 --
// there's always at least one user workspace to its left).
const fallbackIdx = Math.max(0, dashIdx - 1);
return wm.get_workspace_by_index(fallbackIdx);
}
}

View File

@@ -6,6 +6,7 @@
"noEmit": true,
"types": [
"@girs/gnome-shell/ambient",
"@girs/gnome-shell/extensions/global",
"@girs/meta-16/ambient",
"@girs/gio-2.0/ambient",
"@girs/glib-2.0/ambient",