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:
@@ -26,10 +26,16 @@ export default class DashwardExtension extends Extension {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// P2+ components are still placeholders; left commented until each
|
try {
|
||||||
// phase implements them so we don't pretend to be functional.
|
this.guard = new WindowGuard(this.warden);
|
||||||
// this.guard = new WindowGuard();
|
} catch (e) {
|
||||||
// this.container = new ContainerSupervisor();
|
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.entry = new EntryUX();
|
||||||
// this.dbus = new DBusService();
|
// this.dbus = new DBusService();
|
||||||
}
|
}
|
||||||
|
|||||||
4
extension/src/types/globals.d.ts
vendored
4
extension/src/types/globals.d.ts
vendored
@@ -1,6 +1,10 @@
|
|||||||
// GNOME Shell injects a `global` singleton (Shell.Global) into the GJS
|
// GNOME Shell injects a `global` singleton (Shell.Global) into the GJS
|
||||||
// global scope. The @girs/gnome-shell typings don't expose it as a
|
// global scope. The @girs/gnome-shell typings don't expose it as a
|
||||||
// resolvable module path, so we declare just the surface we use.
|
// 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';
|
import type Meta from 'gi://Meta';
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,181 @@
|
|||||||
// Stub: see design §6 — window-defense rules.
|
import type Meta from 'gi://Meta';
|
||||||
// Layer 1: window-added bounce-back. Layer 2: overview drop refusal.
|
|
||||||
// Layer 3: hide from switcher strip. Layer 4: workspace switcher clamp.
|
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 {
|
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 {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"types": [
|
"types": [
|
||||||
"@girs/gnome-shell/ambient",
|
"@girs/gnome-shell/ambient",
|
||||||
|
"@girs/gnome-shell/extensions/global",
|
||||||
"@girs/meta-16/ambient",
|
"@girs/meta-16/ambient",
|
||||||
"@girs/gio-2.0/ambient",
|
"@girs/gio-2.0/ambient",
|
||||||
"@girs/glib-2.0/ambient",
|
"@girs/glib-2.0/ambient",
|
||||||
|
|||||||
Reference in New Issue
Block a user