From 80af9620e0dac76ba2ab1cf15d40c0389f073ea2 Mon Sep 17 00:00:00 2001 From: Raul Benencia Date: Mon, 8 Jun 2026 12:49:56 -0300 Subject: workspace router --- .../extension.js | 375 +++++++++++++++++++++ .../metadata.json | 10 + .../workspace-router@rbenencia.name/extension.js | 25 +- 3 files changed, 404 insertions(+), 6 deletions(-) create mode 100644 .local/share/gnome-shell/extensions/workspace-router-cli@rbenencia.name/extension.js create mode 100644 .local/share/gnome-shell/extensions/workspace-router-cli@rbenencia.name/metadata.json (limited to '.local') diff --git a/.local/share/gnome-shell/extensions/workspace-router-cli@rbenencia.name/extension.js b/.local/share/gnome-shell/extensions/workspace-router-cli@rbenencia.name/extension.js new file mode 100644 index 0000000..8a80b8b --- /dev/null +++ b/.local/share/gnome-shell/extensions/workspace-router-cli@rbenencia.name/extension.js @@ -0,0 +1,375 @@ +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 DBUS_XML = ` + + + + + + + + + + + + + +`; + +const ROUTABLE_WINDOW_TYPES = new Set([ + Meta.WindowType.NORMAL, + Meta.WindowType.DIALOG, + Meta.WindowType.MODAL_DIALOG, +]); + +const WINDOW_RULES = [ + { + name: 'misc', + workspace: 4, + 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: 'main-frame', + workspace: 0, + matchAll: [ + {field: 'appId', pattern: /emacs/i}, + {field: 'title', pattern: /^main$/i}, + ], + }, + { + name: 'browsers', + workspace: 1, + 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, + matchAll: [ + {field: 'appId', pattern: /emacs/i}, + {field: 'title', pattern: /^communications$/i}, + ], + }, + { + name: 'communications-apps', + workspace: 2, + 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, + matchAll: [ + {field: 'appId', pattern: /emacs/i}, + {field: 'title', pattern: /^terminals$/i}, + ], + }, + { + name: 'terminals-apps', + workspace: 3, + 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: 'teleport', + workspace: 5, + matchAny: [ + {field: 'title', pattern: /teleport/i}, + {field: 'appId', pattern: /teleport/i}, + {field: 'wmClass', pattern: /teleport/i}, + {field: 'wmClassInstance', pattern: /teleport/i}, + ], + }, + { + name: 'media', + workspace: 8, + 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, + }; +} + +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 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, + }; +} + +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 WorkspaceRouterCliExtension extends Extension { + enable() { + this._windowTracker = Shell.WindowTracker.get_default(); + 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 CLI lost D-Bus name ${BUS_NAME}`)); + } + + disable() { + if (this._ownName) { + Gio.DBus.session.unown_name(this._ownName); + this._ownName = null; + } + + if (this._dbusImpl) { + this._dbusImpl.unexport(); + this._dbusImpl.run_dispose(); + this._dbusImpl = null; + } + + this._windowTracker = null; + } + + RouteWindowsAsync(_params, invocation) { + try { + invocation.return_value(GLib.Variant.new('(s)', [this._routeWindows()])); + } catch (e) { + logError(e, 'Workspace Router CLI failed to route windows'); + invocation.return_dbus_error(`${INTERFACE_NAME}.Error`, e.message); + } + } + + ListWindowsAsync(_params, invocation) { + try { + invocation.return_value(GLib.Variant.new('(s)', [this._listWindows()])); + } catch (e) { + logError(e, 'Workspace Router CLI failed to list windows'); + invocation.return_dbus_error(`${INTERFACE_NAME}.Error`, e.message); + } + } + + ActivateWindowAsync(params, invocation) { + try { + const [windowId] = params.deepUnpack(); + invocation.return_value(GLib.Variant.new('(s)', [this._activateWindow(windowId)])); + } catch (e) { + logError(e, 'Workspace Router CLI failed to activate window'); + invocation.return_dbus_error(`${INTERFACE_NAME}.Error`, e.message); + } + } + + _routeWindows() { + const result = { + primaryMonitor: Main.layoutManager.primaryIndex, + movedToPrimary: 0, + routed: 0, + skipped: 0, + windows: [], + }; + + for (const window of this._collectWindows()) { + const routeResult = this._routeWindow(window); + + if (!routeResult) { + result.skipped++; + continue; + } + + if (routeResult.movedToPrimary) + result.movedToPrimary++; + + if (routeResult.routed) + result.routed++; + else + result.skipped++; + + result.windows.push(routeResult.window); + } + + return JSON.stringify(result); + } + + _listWindows() { + const windows = this._collectWindows().map(window => { + const info = getWindowInfo(window, this._windowTracker); + const rule = WINDOW_RULES.find(candidate => ruleMatches(info, candidate)); + + return windowInfoForResult(window, info, rule); + }); + + return JSON.stringify(windows); + } + + _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; + } + + _routeWindow(window) { + if (!this._isRoutableWindow(window)) + return null; + + const info = getWindowInfo(window, this._windowTracker); + const rule = WINDOW_RULES.find(candidate => ruleMatches(info, candidate)); + const movedToPrimary = this._moveWindowToMonitor(window, Main.layoutManager.primaryIndex); + + if (rule) + this._moveWindowToWorkspace(window, rule.workspace); + + return { + movedToPrimary, + routed: Boolean(rule), + window: windowInfoForResult(window, info, rule), + }; + } + + _moveWindowToMonitor(window, monitorIndex) { + if (monitorIndex === null || monitorIndex === undefined) + return false; + + if (monitorIndex === window.get_monitor()) + return false; + + const wasFullscreen = window.is_fullscreen(); + const maximized = window.get_maximized(); + + if (wasFullscreen) + window.unmake_fullscreen(); + + if (maximized) + window.unmaximize(maximized); + + window.move_to_monitor(monitorIndex); + + if (maximized) + window.maximize(maximized); + + if (wasFullscreen) + window.make_fullscreen(); + + 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()); + } +} diff --git a/.local/share/gnome-shell/extensions/workspace-router-cli@rbenencia.name/metadata.json b/.local/share/gnome-shell/extensions/workspace-router-cli@rbenencia.name/metadata.json new file mode 100644 index 0000000..c1b99b5 --- /dev/null +++ b/.local/share/gnome-shell/extensions/workspace-router-cli@rbenencia.name/metadata.json @@ -0,0 +1,10 @@ +{ + "uuid": "workspace-router-cli@rbenencia.name", + "extension-id": "workspace-router-cli", + "name": "Workspace Router CLI", + "description": "Expose Raul's GNOME window routing workflow over D-Bus.", + "shell-version": ["46", "47", "48", "49"], + "url": "https://rbenencia.name", + "session-modes": ["user"], + "version": 1 +} 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 74f6cdf..a59c491 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 @@ -45,10 +45,10 @@ const WINDOW_RULES = [ workspace: 2, monitor: 'primary', matchAny: [ - {field: 'title', pattern: /(ebex|lack|communications|notmuch|outlook|elfeed|thunderbird)/i}, - {field: 'appId', pattern: /(slack|outlook|thunderbird)/i}, - {field: 'wmClass', pattern: /(slack|outlook|thunderbird)/i}, - {field: 'wmClassInstance', pattern: /(slack|outlook|thunderbird)/i}, + {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}, ], }, { @@ -87,7 +87,10 @@ const WINDOW_RULES = [ workspace: 4, monitor: 'primary', matchAny: [ - {field: 'title', pattern: /(isco|eepa)/i}, + {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}, ], }, { @@ -144,6 +147,16 @@ function resolveMonitor(monitor) { return null; } +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(); @@ -259,7 +272,7 @@ export default class WorkspaceRouterExtension extends Extension { } _isRoutableWindow(window) { - if (window.skip_taskbar || window.is_override_redirect()) + if (isSkipTaskbar(window) || window.is_override_redirect()) return false; if (window.is_on_all_workspaces()) -- cgit v1.2.3