import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import Meta from 'gi://Meta'; 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', workspace: 0, monitor: 'primary', matchAll: [ {field: 'appId', pattern: /emacs/i}, {field: 'title', pattern: /^main$/i}, ], }, { name: 'browsers', workspace: 1, monitor: 'primary', matchAny: [ {field: 'appId', pattern: /(firefox|chromium|chrome)/i}, {field: 'wmClass', pattern: /(firefox|chromium|chrome)/i}, {field: 'wmClassInstance', pattern: /(firefox|chromium|chrome)/i}, ], }, { name: 'communications-frame', workspace: 2, monitor: 'primary', matchAll: [ {field: 'appId', pattern: /emacs/i}, {field: 'title', pattern: /^communications$/i}, ], }, { name: 'communications-apps', workspace: 2, monitor: 'primary', matchAny: [ {field: 'title', pattern: /(webex|slack|communications|notmuch|outlook|elfeed|thunderbird)/i}, {field: 'appId', pattern: /(webex|slack|outlook|thunderbird)/i}, {field: 'wmClass', pattern: /(webex|slack|outlook|thunderbird)/i}, {field: 'wmClassInstance', pattern: /(webex|slack|outlook|thunderbird)/i}, ], }, { name: 'terminals-frame', workspace: 3, monitor: 'primary', matchAll: [ {field: 'appId', pattern: /emacs/i}, {field: 'title', pattern: /^terminals$/i}, ], }, { name: 'teleport', workspace: 5, monitor: 'primary', matchAny: [ {field: 'title', pattern: /teleport/i}, {field: 'appId', pattern: /teleport/i}, {field: 'wmClass', pattern: /teleport/i}, {field: 'wmClassInstance', pattern: /teleport/i}, ], }, { name: 'terminals-apps', workspace: 3, monitor: 'primary', matchAny: [ {field: 'title', pattern: /(alacritty|kitty|terminal)/i}, {field: 'appId', pattern: /(alacritty|kitty|console|terminal)/i}, {field: 'wmClass', pattern: /(alacritty|kitty|terminal)/i}, {field: 'wmClassInstance', pattern: /(alacritty|kitty|terminal)/i}, ], }, { name: 'misc', workspace: 4, monitor: 'primary', matchAny: [ {field: 'title', pattern: /(cisco secure client|secure client|anyconnect|vpnui|keepass)/i}, {field: 'appId', pattern: /(cisco|secureclient|anyconnect|vpnui|keepass)/i}, {field: 'wmClass', pattern: /(cisco|secureclient|anyconnect|vpnui|keepass)/i}, {field: 'wmClassInstance', pattern: /(cisco|secureclient|anyconnect|vpnui|keepass)/i}, ], }, { name: 'media', workspace: 8, monitor: 'primary', matchAny: [ {field: 'title', pattern: /(youtube|spotify)/i}, {field: 'appId', pattern: /spotify/i}, {field: 'wmClass', pattern: /spotify/i}, {field: 'wmClassInstance', pattern: /spotify/i}, ], }, ]; function getWindowInfo(window, windowTracker) { const app = windowTracker.get_window_app(window); return { title: window.get_title() ?? '', wmClass: window.get_wm_class() ?? '', wmClassInstance: window.get_wm_class_instance() ?? '', 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(), }; } function matchCondition(info, condition) { const value = info[condition.field] ?? ''; return condition.pattern.test(value); } function ruleMatches(info, rule) { const matchAll = rule.matchAll ?? []; const matchAny = rule.matchAny ?? []; if (!matchAll.every(condition => matchCondition(info, condition))) return false; if (matchAny.length === 0) return true; return matchAny.some(condition => matchCondition(info, condition)); } function resolveMonitor(monitor) { if (monitor === 'primary') return Main.layoutManager.primaryIndex; if (Number.isInteger(monitor)) return 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(); if (typeof window.skip_taskbar === 'function') return window.skip_taskbar(); return Boolean(window.skip_taskbar); } export default class WorkspaceRouterExtension extends Extension { enable() { this._windowTracker = Shell.WindowTracker.get_default(); 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); 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 sourceId of this._pendingEvaluations.values()) GLib.source_remove(sourceId); for (const sourceId of this._windowStateSourceIds.values()) GLib.source_remove(sourceId); 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; } 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; 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); } _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() { for (const window of this._trackedWindows) this._queueWindow(window); } _queueWindow(window) { if (!this._trackedWindows.has(window) || this._routedWindows.has(window)) return; this._clearPendingEvaluation(window); const sourceId = GLib.timeout_add( GLib.PRIORITY_DEFAULT, EVALUATE_DELAY_MS, () => { this._pendingEvaluations.delete(window); this._routeTrackedWindow(window); return GLib.SOURCE_REMOVE; }); this._pendingEvaluations.set(window, sourceId); } _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._moveWindowToMonitor(window, resolveMonitor(rule.monitor)); this._moveWindowToWorkspace(window, rule.workspace); this._routedWindows.add(window); } _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)); } 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() !== 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()); } _isRoutableWindow(window) { if (isSkipTaskbar(window) || window.is_override_redirect()) return false; if (window.is_on_all_workspaces()) return false; return ROUTABLE_WINDOW_TYPES.has(window.get_window_type()); } _clearPendingEvaluation(window) { const sourceId = this._pendingEvaluations.get(window); if (sourceId) { GLib.source_remove(sourceId); this._pendingEvaluations.delete(window); } } _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); } }