From 1b856a6b31b8f8369256612cef6cc242486534a2 Mon Sep 17 00:00:00 2001 From: Raul Benencia Date: Wed, 12 Aug 2026 13:32:16 -0700 Subject: gnome: consolidate workspace-router extension --- .../workspace-router@rbenencia.name/extension.js | 352 +++++++++++++++++++-- 1 file changed, 319 insertions(+), 33 deletions(-) (limited to '.local/share/gnome-shell/extensions/workspace-router@rbenencia.name/extension.js') diff --git a/.local/share/gnome-shell/extensions/workspace-router@rbenencia.name/extension.js b/.local/share/gnome-shell/extensions/workspace-router@rbenencia.name/extension.js index 525a5c8..1ff20bb 100644 --- a/.local/share/gnome-shell/extensions/workspace-router@rbenencia.name/extension.js +++ b/.local/share/gnome-shell/extensions/workspace-router@rbenencia.name/extension.js @@ -1,3 +1,4 @@ +import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import Meta from 'gi://Meta'; import Shell from 'gi://Shell'; @@ -5,12 +6,35 @@ import Shell from 'gi://Shell'; import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +const BUS_NAME = 'name.rbenencia.WorkspaceRouter'; +const OBJECT_PATH = '/name/rbenencia/WorkspaceRouter'; +const INTERFACE_NAME = 'name.rbenencia.WorkspaceRouter'; const EVALUATE_DELAY_MS = 150; +const MONITOR_SETTLE_DELAY_MS = 1500; +const WINDOW_STATE_SETTLE_DELAY_MS = 250; + +const DBUS_XML = ` + + + + + + + + + + + + + +`; + const ROUTABLE_WINDOW_TYPES = new Set([ Meta.WindowType.NORMAL, Meta.WindowType.DIALOG, Meta.WindowType.MODAL_DIALOG, ]); + const WINDOW_RULES = [ { name: 'main-frame', @@ -116,6 +140,10 @@ function getWindowInfo(window, windowTracker) { appId: app?.get_id() ?? '', appName: app?.get_name() ?? '', role: window.get_role() ?? '', + monitor: window.get_monitor(), + workspace: window.get_workspace()?.index() ?? null, + maximizeFlags: window.get_maximized(), + fullscreen: window.is_fullscreen(), }; } @@ -147,6 +175,26 @@ function resolveMonitor(monitor) { return null; } +function windowInfoForResult(window, info, rule) { + return { + id: window.get_stable_sequence(), + title: info.title, + appId: info.appId, + appName: info.appName, + wmClass: info.wmClass, + wmClassInstance: info.wmClassInstance, + role: info.role, + monitor: info.monitor, + workspace: info.workspace, + route: rule?.name ?? null, + targetWorkspace: rule?.workspace ?? null, + finalMonitor: window.get_monitor(), + finalWorkspace: window.get_workspace()?.index() ?? null, + finalMaximizeFlags: window.get_maximized(), + finalFullscreen: window.is_fullscreen(), + }; +} + function isSkipTaskbar(window) { if (typeof window.is_skip_taskbar === 'function') return window.is_skip_taskbar(); @@ -163,56 +211,222 @@ export default class WorkspaceRouterExtension extends Extension { this._trackedWindows = new Set(); this._routedWindows = new Set(); this._pendingEvaluations = new Map(); + this._windowStates = new Map(); + this._windowStateSourceIds = new Map(); + this._preservedWindowStates = null; + this._monitorSettleSourceId = 0; + this._lastMonitorLayout = this._monitorLayoutSignature(); + + this._exportDbusInterface(); global.display.connectObject( 'window-created', (_display, window) => this._trackWindow(window), this); - this._windowTracker.connectObject( 'tracked-windows-changed', () => this._queueUnmatchedWindows(), this); + Main.layoutManager.connectObject( + 'monitors-changed', () => this._queueMonitorLayoutRoute(), + this); - // User-mode extensions are disabled while the screen is locked and - // re-enabled before the monitor layout has necessarily settled. Do - // not treat existing windows as newly created here: doing so can move - // them to the laptop while it is temporarily the primary monitor. - // Existing windows can still be routed explicitly through the CLI. + for (const actor of global.get_window_actors()) + this._trackWindow(actor.meta_window, false); + + // Do not route existing windows merely because the extension was + // enabled. On unlock, extensions can start before the external display + // and primary-monitor state have settled. A real monitors-changed event + // is debounced below and routes against the final Shell layout. } disable() { + if (this._monitorSettleSourceId) + GLib.source_remove(this._monitorSettleSourceId); + this._monitorSettleSourceId = 0; + global.display.disconnectObject(this); + Main.layoutManager.disconnectObject(this); this._windowTracker?.disconnectObject(this); - for (const [window, sourceId] of this._pendingEvaluations) { + for (const sourceId of this._pendingEvaluations.values()) + GLib.source_remove(sourceId); + for (const sourceId of this._windowStateSourceIds.values()) GLib.source_remove(sourceId); - window.disconnectObject(this); - } - for (const window of this._trackedWindows) window.disconnectObject(this); + this._unexportDbusInterface(); this._pendingEvaluations.clear(); + this._windowStateSourceIds.clear(); + this._windowStates.clear(); + this._preservedWindowStates = null; this._trackedWindows.clear(); this._routedWindows.clear(); this._windowTracker = null; } - _trackWindow(window) { - if (!window || this._trackedWindows.has(window)) + RouteWindowsAsync(_params, invocation) { + try { + invocation.return_value(GLib.Variant.new('(s)', [this._routeAllWindows()])); + } catch (error) { + logError(error, 'Workspace Router failed to route windows'); + invocation.return_dbus_error(`${INTERFACE_NAME}.Error`, error.message); + } + } + + ListWindowsAsync(_params, invocation) { + try { + invocation.return_value(GLib.Variant.new('(s)', [this._listWindows()])); + } catch (error) { + logError(error, 'Workspace Router failed to list windows'); + invocation.return_dbus_error(`${INTERFACE_NAME}.Error`, error.message); + } + } + + ActivateWindowAsync(params, invocation) { + try { + const [windowId] = params.deepUnpack(); + invocation.return_value(GLib.Variant.new('(s)', [this._activateWindow(windowId)])); + } catch (error) { + logError(error, 'Workspace Router failed to activate window'); + invocation.return_dbus_error(`${INTERFACE_NAME}.Error`, error.message); + } + } + + _exportDbusInterface() { + this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(DBUS_XML, this); + this._dbusImpl.export(Gio.DBus.session, OBJECT_PATH); + this._ownName = Gio.DBus.session.own_name( + BUS_NAME, + Gio.BusNameOwnerFlags.REPLACE, + () => {}, + () => log(`Workspace Router lost D-Bus name ${BUS_NAME}`)); + } + + _unexportDbusInterface() { + if (this._ownName) { + Gio.DBus.session.unown_name(this._ownName); + this._ownName = 0; + } + + if (this._dbusImpl) { + this._dbusImpl.unexport(); + this._dbusImpl.run_dispose(); + this._dbusImpl = null; + } + } + + _queueMonitorLayoutRoute() { + if (!this._preservedWindowStates) + this._preserveWindowStates(); + + if (this._monitorSettleSourceId) + GLib.source_remove(this._monitorSettleSourceId); + + this._monitorSettleSourceId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + MONITOR_SETTLE_DELAY_MS, + () => { + this._monitorSettleSourceId = 0; + const monitorLayout = this._monitorLayoutSignature(); + try { + if (monitorLayout !== this._lastMonitorLayout) { + const result = JSON.parse(this._routeAllWindows()); + this._lastMonitorLayout = monitorLayout; + log(`Workspace Router handled monitor layout change: ` + + `${result.routed} routed, ${result.movedToPrimary} moved to primary`); + } + this._restoreWindowStates(); + } catch (error) { + logError(error, 'Workspace Router failed after monitor layout change'); + } finally { + this._finishWindowStatePreservation(); + } + return GLib.SOURCE_REMOVE; + }); + } + + _monitorLayoutSignature() { + return JSON.stringify({ + primary: Main.layoutManager.primaryIndex, + monitors: Main.layoutManager.monitors.map( + ({x, y, width, height}) => [x, y, width, height]), + }); + } + + _preserveWindowStates() { + for (const sourceId of this._windowStateSourceIds.values()) + GLib.source_remove(sourceId); + this._windowStateSourceIds.clear(); + this._preservedWindowStates = new Map(this._windowStates); + } + + _restoreWindowStates() { + if (!this._preservedWindowStates) return; - if (!this._isRoutableWindow(window)) + for (const [window, state] of this._preservedWindowStates) { + if (!this._isRoutableWindow(window)) + continue; + + const missingMaximizeFlags = state.maximizeFlags & ~window.get_maximized(); + if (missingMaximizeFlags) + window.maximize(missingMaximizeFlags); + if (state.fullscreen && !window.is_fullscreen()) + window.make_fullscreen(); + } + } + + _finishWindowStatePreservation() { + this._preservedWindowStates = null; + for (const window of this._trackedWindows) + this._queueWindowStateUpdate(window); + } + + _trackWindow(window, routeWhenReady = true) { + if (!window || this._trackedWindows.has(window) || !this._isRoutableWindow(window)) return; this._trackedWindows.add(window); - + this._windowStates.set(window, this._windowState(window)); window.connectObject( 'notify::title', () => this._queueWindow(window), 'notify::wm-class', () => this._queueWindow(window), + 'notify::maximized-horizontally', () => this._queueWindowStateUpdate(window), + 'notify::maximized-vertically', () => this._queueWindowStateUpdate(window), + 'notify::fullscreen', () => this._queueWindowStateUpdate(window), 'unmanaged', () => this._cleanupWindow(window), this); + if (routeWhenReady) + this._queueWindow(window); + else + this._routedWindows.add(window); + } - this._queueWindow(window); + _windowState(window) { + return { + maximizeFlags: window.get_maximized(), + fullscreen: window.is_fullscreen(), + }; + } + + _queueWindowStateUpdate(window) { + const previousSourceId = this._windowStateSourceIds.get(window); + if (previousSourceId) + GLib.source_remove(previousSourceId); + + if (this._preservedWindowStates) + return; + + const sourceId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + WINDOW_STATE_SETTLE_DELAY_MS, + () => { + this._windowStateSourceIds.delete(window); + if (this._trackedWindows.has(window) && !this._preservedWindowStates) + this._windowStates.set(window, this._windowState(window)); + return GLib.SOURCE_REMOVE; + }); + this._windowStateSourceIds.set(window, sourceId); } _queueUnmatchedWindows() { @@ -225,51 +439,117 @@ export default class WorkspaceRouterExtension extends Extension { return; this._clearPendingEvaluation(window); - const sourceId = GLib.timeout_add( GLib.PRIORITY_DEFAULT, EVALUATE_DELAY_MS, () => { this._pendingEvaluations.delete(window); - this._routeWindow(window); + this._routeTrackedWindow(window); return GLib.SOURCE_REMOVE; }); - this._pendingEvaluations.set(window, sourceId); } - _routeWindow(window) { - if (!this._trackedWindows.has(window) || this._routedWindows.has(window)) - return; - - if (!this._isRoutableWindow(window)) + _routeTrackedWindow(window) { + if (!this._trackedWindows.has(window) || this._routedWindows.has(window) || + !this._isRoutableWindow(window)) return; const info = getWindowInfo(window, this._windowTracker); const rule = WINDOW_RULES.find(candidate => ruleMatches(info, candidate)); - if (!rule) return; - this._moveWindow(window, rule); + this._moveWindowToMonitor(window, resolveMonitor(rule.monitor)); + this._moveWindowToWorkspace(window, rule.workspace); this._routedWindows.add(window); } - _moveWindow(window, rule) { - this._ensureWorkspace(rule.workspace); + _routeAllWindows() { + const result = { + primaryMonitor: Main.layoutManager.primaryIndex, + movedToPrimary: 0, + routed: 0, + skipped: 0, + windows: [], + }; + + for (const window of this._collectWindows()) { + const info = getWindowInfo(window, this._windowTracker); + const rule = WINDOW_RULES.find(candidate => ruleMatches(info, candidate)); + let targetMonitor = Main.layoutManager.primaryIndex; + if (rule) + targetMonitor = resolveMonitor(rule.monitor); + const movedToPrimary = this._moveWindowToMonitor(window, targetMonitor); + + if (rule) { + this._moveWindowToWorkspace(window, rule.workspace); + this._routedWindows.add(window); + result.routed++; + } else { + result.skipped++; + } + if (movedToPrimary) + result.movedToPrimary++; + + result.windows.push(windowInfoForResult(window, info, rule)); + } - const monitorIndex = resolveMonitor(rule.monitor); - if (monitorIndex !== null && monitorIndex !== window.get_monitor()) - window.move_to_monitor(monitorIndex); + return JSON.stringify(result); + } + + _listWindows() { + return JSON.stringify(this._collectWindows().map(window => { + const info = getWindowInfo(window, this._windowTracker); + const rule = WINDOW_RULES.find(candidate => ruleMatches(info, candidate)); + return windowInfoForResult(window, info, rule); + })); + } + _activateWindow(windowId) { + const window = this._collectWindows().find( + candidate => candidate.get_stable_sequence() === windowId); + if (!window) + throw new Error(`Window not found: ${windowId}`); + + Main.activateWindow(window); + return JSON.stringify({activated: true, id: windowId}); + } + + _collectWindows() { + const windows = []; + const seen = new Set(); + for (const actor of global.get_window_actors()) { + const window = actor.meta_window; + if (!window || seen.has(window) || !this._isRoutableWindow(window)) + continue; + seen.add(window); + windows.push(window); + } + return windows; + } + + _moveWindowToMonitor(window, monitorIndex) { + if (monitorIndex === null || monitorIndex === undefined || + monitorIndex === window.get_monitor()) + return false; + + // Mutter preserves maximization and fullscreen state when moving a + // window. Unmaximizing first introduces an asynchronous X11 race that + // can leave the window restored after a monitor transition. + window.move_to_monitor(monitorIndex); + return true; + } + + _moveWindowToWorkspace(window, workspaceIndex) { + this._ensureWorkspace(workspaceIndex); const workspace = window.get_workspace(); - if (!workspace || workspace.index() !== rule.workspace) - window.change_workspace_by_index(rule.workspace, false); + if (!workspace || workspace.index() !== workspaceIndex) + window.change_workspace_by_index(workspaceIndex, false); } _ensureWorkspace(index) { const workspaceManager = global.workspace_manager; - while (workspaceManager.n_workspaces <= index) workspaceManager.append_new_workspace(false, global.get_current_time()); } @@ -294,6 +574,12 @@ export default class WorkspaceRouterExtension extends Extension { _cleanupWindow(window) { this._clearPendingEvaluation(window); + const stateSourceId = this._windowStateSourceIds.get(window); + if (stateSourceId) + GLib.source_remove(stateSourceId); + this._windowStateSourceIds.delete(window); + this._windowStates.delete(window); + this._preservedWindowStates?.delete(window); this._trackedWindows.delete(window); this._routedWindows.delete(window); window.disconnectObject(this); -- cgit v1.2.3