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()); } }