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) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-23 00:06:49 +01:00
parent 948dfb0c57
commit 5b871b8ecb
2 changed files with 38 additions and 7 deletions

View File

@@ -1,4 +1,4 @@
import type Meta from 'gi://Meta'; import Meta from 'gi://Meta';
import { log, warn } from './util/logger.js'; import { log, warn } from './util/logger.js';
import type { WorkspaceWarden } from './workspace-warden.js'; import type { WorkspaceWarden } from './workspace-warden.js';
@@ -101,6 +101,8 @@ export class WindowGuard {
private trackWindow(win: Meta.Window): void { private trackWindow(win: Meta.Window): void {
if (this.trackedWindows.has(win)) return; if (this.trackedWindows.has(win)) return;
if (win.is_override_redirect()) 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. // Record current non-dashboard workspace if any.
const ws = win.get_workspace(); const ws = win.get_workspace();
@@ -140,21 +142,46 @@ export class WindowGuard {
if (this.disposed) return; if (this.disposed) return;
if (this.whitelist.has(win)) return; if (this.whitelist.has(win)) return;
if (win.is_override_redirect()) return; if (win.is_override_redirect()) return;
if (this.shouldSkip(win)) return;
// Make sure we're tracking it (covers windows mapped directly onto // Make sure we're tracking it (covers windows mapped directly onto
// dashboard before workspace-changed could fire). // dashboard before workspace-changed could fire).
if (!this.trackedWindows.has(win)) this.trackWindow(win); if (!this.trackedWindows.has(win)) this.trackWindow(win);
const title = win.get_title?.() ?? '?';
const fallback = this.resolveFallback(win); const fallback = this.resolveFallback(win);
if (!fallback) { if (!fallback) {
warn(`bounceIfNeeded: no fallback workspace found for ${win.get_title?.() ?? '?'}`); warn(`bounceIfNeeded: no fallback workspace for "${title}"`);
return; 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); 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 { private resolveFallback(win: Meta.Window): Meta.Workspace | null {
const wm = global.workspace_manager; const wm = global.workspace_manager;
const dashIdx = this.warden.getDashboardIndex(); const dashIdx = this.warden.getDashboardIndex();

View File

@@ -98,11 +98,15 @@ export class WorkspaceWarden {
}; };
saveState(STATE_NAME, this.saved); saveState(STATE_NAME, this.saved);
// Order matters: turn off dynamic FIRST so mutter doesn't react to our // Order matters. The defaults on a fresh GNOME session are
// append by GC'ing it back. Setting dynamic=false makes the count // dynamic-workspaces=true and num-workspaces=4 (the latter is unused
// authoritative from num-workspaces; we'll update num-workspaces to // while dynamic mode is on). If we flip dynamic=false first, mutter
// match after the append. // 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=<current count> first, then flipping dynamic, then
// appending the dashboard, then updating num-workspaces to the new count.
this.suppressGuard = true; this.suppressGuard = true;
this.wmSettings.set_int(KEY_NUM, n);
this.mutterSettings.set_boolean(KEY_DYNAMIC, false); this.mutterSettings.set_boolean(KEY_DYNAMIC, false);
this.dashboardWs = wm.append_new_workspace(false, global.get_current_time()); this.dashboardWs = wm.append_new_workspace(false, global.get_current_time());