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 EVALUATE_DELAY_MS = 150; 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: /(ebex|lack|communications|notmuch|outlook|elfeed)/i}, {field: 'appId', pattern: /(slack|outlook)/i}, {field: 'wmClass', pattern: /(slack|outlook)/i}, {field: 'wmClassInstance', pattern: /(slack|outlook)/i}, ], }, { name: 'terminals-frame', workspace: 3, monitor: 'primary', matchAll: [ {field: 'appId', pattern: /emacs/i}, {field: 'title', pattern: /^terminals$/i}, ], }, { name: 'terminals-apps', workspace: 3, monitor: 'primary', matchAny: [ {field: 'title', pattern: /(alacritty|kitty|terminal|teleport)/i}, {field: 'appId', pattern: /(alacritty|kitty|console|terminal|teleport)/i}, {field: 'wmClass', pattern: /(alacritty|kitty|terminal|teleport)/i}, {field: 'wmClassInstance', pattern: /(alacritty|kitty|terminal|teleport)/i}, ], }, { name: 'misc', workspace: 4, monitor: 'primary', matchAny: [ {field: 'title', pattern: /(isco|eepa)/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() ?? '', }; } 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; } 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(); global.display.connectObject( 'window-created', (_display, window) => this._trackWindow(window), this); this._windowTracker.connectObject( 'tracked-windows-changed', () => this._queueUnmatchedWindows(), this); for (const actor of global.get_window_actors()) this._trackWindow(actor.meta_window); } disable() { global.display.disconnectObject(this); this._windowTracker?.disconnectObject(this); for (const [window, sourceId] of this._pendingEvaluations) { GLib.source_remove(sourceId); window.disconnectObject(this); } for (const window of this._trackedWindows) window.disconnectObject(this); this._pendingEvaluations.clear(); this._trackedWindows.clear(); this._routedWindows.clear(); this._windowTracker = null; } _trackWindow(window) { if (!window || this._trackedWindows.has(window)) return; if (!this._isRoutableWindow(window)) return; this._trackedWindows.add(window); window.connectObject( 'notify::title', () => this._queueWindow(window), 'notify::wm-class', () => this._queueWindow(window), 'unmanaged', () => this._cleanupWindow(window), this); this._queueWindow(window); } _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._routeWindow(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)) return; const info = getWindowInfo(window, this._windowTracker); const rule = WINDOW_RULES.find(candidate => ruleMatches(info, candidate)); if (!rule) return; this._moveWindow(window, rule); this._routedWindows.add(window); } _moveWindow(window, rule) { this._ensureWorkspace(rule.workspace); const monitorIndex = resolveMonitor(rule.monitor); if (monitorIndex !== null && monitorIndex !== window.get_monitor()) window.move_to_monitor(monitorIndex); const workspace = window.get_workspace(); if (!workspace || workspace.index() !== rule.workspace) window.change_workspace_by_index(rule.workspace, 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 (window.skip_taskbar || 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); this._trackedWindows.delete(window); this._routedWindows.delete(window); window.disconnectObject(this); } }