From 948dfb0c57d21c299eeb52bca87b5d5d07ba9923 Mon Sep 17 00:00:00 2001 From: hzhang Date: Fri, 22 May 2026 23:12:44 +0100 Subject: [PATCH] =?UTF-8?q?feat(extension):=20P2=20=E2=80=94=20WindowGuard?= =?UTF-8?q?=20Layer=201=20(bounce-back)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, 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 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) --- extension/src/extension.ts | 14 ++- extension/src/types/globals.d.ts | 4 + extension/src/window-guard.ts | 180 ++++++++++++++++++++++++++++++- extension/tsconfig.json | 1 + 4 files changed, 191 insertions(+), 8 deletions(-) diff --git a/extension/src/extension.ts b/extension/src/extension.ts index bcd12ed..44cba73 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -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(); } diff --git a/extension/src/types/globals.d.ts b/extension/src/types/globals.d.ts index 3260287..1cf8584 100644 --- a/extension/src/types/globals.d.ts +++ b/extension/src/types/globals.d.ts @@ -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'; diff --git a/extension/src/window-guard.ts b/extension/src/window-guard.ts index 33b3f1c..3bdf9e4 100644 --- a/extension/src/window-guard.ts +++ b/extension/src/window-guard.ts @@ -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(); + private readonly previousWs = new WeakMap(); + private readonly trackedWindows = new Set(); + + 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); } } diff --git a/extension/tsconfig.json b/extension/tsconfig.json index 4cd9034..9f88aa4 100644 --- a/extension/tsconfig.json +++ b/extension/tsconfig.json @@ -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",