From 5b871b8ecb6ae6b86b10da2ee4de8ec9aeb4765b Mon Sep 17 00:00:00 2001 From: hzhang Date: Sat, 23 May 2026 00:06:49 +0100 Subject: [PATCH] fix(extension): WindowGuard skips desktop-icons-ng; harden warden ordering P2 went onto the VM and the dashboard workspace appeared, but switching to it auto-bounced back to the previous workspace. Diagnostic build showed every `window-added` on dashboard was firing for a window with title `@!0,0;BDHF`, wm_class=gjs, type=NORMAL, sticky=false. That's the desktop-icons-ng (ding) extension's per-monitor desktop window: it runs as a separate gjs process, so it masquerades as a generic NORMAL window and the original DESKTOP/DOCK/SPLASHSCREEN filter missed it. WindowGuard was un-sticking it on every workspace switch, which caused mutter to flip the active workspace away from dashboard. WindowGuard.shouldSkip now also returns true for `is_skip_taskbar()` windows. That's the load-bearing check for ding, and a sensible general rule: skip-taskbar windows are owner-managed (PiP, helper utilities, backgrounded ding desktops) and shouldn't be dragged around by us. The DESKTOP/DOCK/SPLASHSCREEN check stays for extensions that do label themselves correctly. While re-deploying, also harden the WorkspaceWarden enable() ordering. The original sequence relied on mutter not reacting to the `dynamic-workspaces=false` flip before our `append_new_workspace` call landed -- but with default gsettings (num-workspaces=4), a faster reaction would expand the count to 4 and leave us with ghost workspaces. Reorder to: write num-workspaces=current first, then flip dynamic, then append, then update num-workspaces. The race is now closed regardless of mutter timing. Verified on ubuntu2504-test VM: dashboard workspace switchable, ding silently ignored, real-window bounces still log on Super+Shift+End. Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/src/window-guard.ts | 33 ++++++++++++++++++++++++++++--- extension/src/workspace-warden.ts | 12 +++++++---- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/extension/src/window-guard.ts b/extension/src/window-guard.ts index 3bdf9e4..53fd3e5 100644 --- a/extension/src/window-guard.ts +++ b/extension/src/window-guard.ts @@ -1,4 +1,4 @@ -import type Meta from 'gi://Meta'; +import Meta from 'gi://Meta'; import { log, warn } from './util/logger.js'; import type { WorkspaceWarden } from './workspace-warden.js'; @@ -101,6 +101,8 @@ export class WindowGuard { private trackWindow(win: Meta.Window): void { if (this.trackedWindows.has(win)) return; if (win.is_override_redirect()) return; + // Don't bother tracking windows we'd never bounce. + if (this.shouldSkip(win)) return; // Record current non-dashboard workspace if any. const ws = win.get_workspace(); @@ -140,21 +142,46 @@ export class WindowGuard { if (this.disposed) return; if (this.whitelist.has(win)) return; if (win.is_override_redirect()) return; + if (this.shouldSkip(win)) 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 title = win.get_title?.() ?? '?'; const fallback = this.resolveFallback(win); if (!fallback) { - warn(`bounceIfNeeded: no fallback workspace found for ${win.get_title?.() ?? '?'}`); + warn(`bounceIfNeeded: no fallback workspace for "${title}"`); return; } - log(`bouncing "${win.get_title?.() ?? '?'}" from dashboard to ws ${fallback.index()}`); + const wmClass = win.get_wm_class?.() ?? '?'; + log(`bouncing "${title}" cls=${wmClass} type=${win.get_window_type()} → ws ${fallback.index()}`); win.change_workspace(fallback); } + // Windows we never bounce: anything the user can't normally reach via + // the taskbar / dock (ding's @!0,0;BDHF desktop window, PiP popups, + // helper utility windows), windows pinned across all workspaces, and + // windows that explicitly declare themselves as desktop / dock / + // splash. Moving these triggers surprising side effects — un-sticking + // ding's desktop window in particular causes the active workspace to + // bounce, which is what manifested in the first VM test. + // + // NB ding masquerades as a generic GJS NORMAL window (wm_class=gjs, + // type=NORMAL, sticky=false), so DESKTOP/DOCK type matching alone + // misses it -- the load-bearing check here is is_skip_taskbar(). + private shouldSkip(win: Meta.Window): boolean { + if (win.is_on_all_workspaces()) return true; + if (win.is_skip_taskbar()) return true; + const t = win.get_window_type(); + return ( + t === Meta.WindowType.DESKTOP || + t === Meta.WindowType.DOCK || + t === Meta.WindowType.SPLASHSCREEN + ); + } + private resolveFallback(win: Meta.Window): Meta.Workspace | null { const wm = global.workspace_manager; const dashIdx = this.warden.getDashboardIndex(); diff --git a/extension/src/workspace-warden.ts b/extension/src/workspace-warden.ts index 41f6f97..c1e2033 100644 --- a/extension/src/workspace-warden.ts +++ b/extension/src/workspace-warden.ts @@ -98,11 +98,15 @@ export class WorkspaceWarden { }; saveState(STATE_NAME, this.saved); - // Order matters: turn off dynamic FIRST so mutter doesn't react to our - // append by GC'ing it back. Setting dynamic=false makes the count - // authoritative from num-workspaces; we'll update num-workspaces to - // match after the append. + // Order matters. The defaults on a fresh GNOME session are + // dynamic-workspaces=true and num-workspaces=4 (the latter is unused + // while dynamic mode is on). If we flip dynamic=false first, mutter + // will react to the stale num-workspaces=4 and expand the count to 4 + // (creating up to N+1 ghost workspaces). Avoid that race by writing + // num-workspaces= first, then flipping dynamic, then + // appending the dashboard, then updating num-workspaces to the new count. this.suppressGuard = true; + this.wmSettings.set_int(KEY_NUM, n); this.mutterSettings.set_boolean(KEY_DYNAMIC, false); this.dashboardWs = wm.append_new_workspace(false, global.get_current_time());