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 +- bin/gnome-window-switcher | 72 ++++ 4 files changed, 476 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 create mode 100755 bin/gnome-window-switcher 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()) diff --git a/bin/gnome-window-switcher b/bin/gnome-window-switcher new file mode 100755 index 0000000..87cbcdc --- /dev/null +++ b/bin/gnome-window-switcher @@ -0,0 +1,72 @@ +#!/bin/sh + +workspace_router_call() { + method="$1" + shift + + command -v gdbus >/dev/null 2>&1 || return 1 + + gdbus call \ + --session \ + --dest name.rbenencia.WorkspaceRouter \ + --object-path /name/rbenencia/WorkspaceRouter \ + --method "name.rbenencia.WorkspaceRouter.$method" \ + "$@" +} + +list_windows_json() { + workspace_router_call ListWindows | + sed -n "s/^('\\(.*\\)',)\$/\\1/p" +} + +activate_window() { + window_id="$1" + workspace_router_call ActivateWindow "uint32:$window_id" >/dev/null +} + +command -v jq >/dev/null 2>&1 || { + echo "gnome-window-switcher: jq is required" >&2 + exit 1 +} + +command -v rofi >/dev/null 2>&1 || { + echo "gnome-window-switcher: rofi is required" >&2 + exit 1 +} + +windows_json=$(list_windows_json) || { + echo "gnome-window-switcher: Workspace Router is unavailable" >&2 + exit 1 +} + +choices=$( + printf '%s\n' "$windows_json" | + jq -r ' + sort_by(.workspace, .appName, .title) + | .[] + | [.id, ("[" + ((.workspace + 1) | tostring) + "]"), (.appName // .wmClass // "Unknown"), (.title // "")] + | @tsv + ' +) + +[ -n "$choices" ] || exit 0 + +# GNOME invokes custom keybindings before the shortcut is fully released. +# Give rofi a moment, then force the keyboard grab and focus takeover. +sleep 0.1 + +selection=$( + printf '%s\n' "$choices" | + cut -f2- | + rofi -dmenu -i -p "window" -format i -no-lazy-grab -steal-focus +) || exit 0 + +window_id=$( + printf '%s\n' "$choices" | + sed -n "$((selection + 1))p" | + cut -f1 +) + +[ -n "$window_id" ] || exit 1 + +activate_window "$window_id" -- cgit v1.2.3