aboutsummaryrefslogtreecommitdiff
path: root/.local/share
diff options
context:
space:
mode:
authorRaul Benencia <raul@thousandeyes.com>2026-08-12 13:32:16 -0700
committerRaul Benencia <raul@thousandeyes.com>2026-08-12 13:32:16 -0700
commit1b856a6b31b8f8369256612cef6cc242486534a2 (patch)
treea3ebedc60b1f9ebc2c2c76175c601b3542644f2a /.local/share
parent2543fba39dbd61d8af14b7d5e3ef3d1e08ba1012 (diff)
gnome: consolidate workspace-router extension
Diffstat (limited to '.local/share')
-rw-r--r--.local/share/gnome-shell/extensions/workspace-router-cli@rbenencia.name/extension.js375
-rw-r--r--.local/share/gnome-shell/extensions/workspace-router-cli@rbenencia.name/metadata.json10
-rw-r--r--.local/share/gnome-shell/extensions/workspace-router@rbenencia.name/extension.js352
-rw-r--r--.local/share/gnome-shell/extensions/workspace-router@rbenencia.name/metadata.json4
4 files changed, 321 insertions, 420 deletions
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
deleted file mode 100644
index 8a80b8b..0000000
--- a/.local/share/gnome-shell/extensions/workspace-router-cli@rbenencia.name/extension.js
+++ /dev/null
@@ -1,375 +0,0 @@
-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 = `
-<node>
- <interface name="${INTERFACE_NAME}">
- <method name="RouteWindows">
- <arg type="s" name="result" direction="out"/>
- </method>
- <method name="ListWindows">
- <arg type="s" name="result" direction="out"/>
- </method>
- <method name="ActivateWindow">
- <arg type="u" name="windowId" direction="in"/>
- <arg type="s" name="result" direction="out"/>
- </method>
- </interface>
-</node>`;
-
-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
deleted file mode 100644
index c1b99b5..0000000
--- a/.local/share/gnome-shell/extensions/workspace-router-cli@rbenencia.name/metadata.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "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 525a5c8..1ff20bb 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
@@ -1,3 +1,4 @@
+import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import Meta from 'gi://Meta';
import Shell from 'gi://Shell';
@@ -5,12 +6,35 @@ 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 = `
+<node>
+ <interface name="${INTERFACE_NAME}">
+ <method name="RouteWindows">
+ <arg type="s" name="result" direction="out"/>
+ </method>
+ <method name="ListWindows">
+ <arg type="s" name="result" direction="out"/>
+ </method>
+ <method name="ActivateWindow">
+ <arg type="u" name="windowId" direction="in"/>
+ <arg type="s" name="result" direction="out"/>
+ </method>
+ </interface>
+</node>`;
+
const ROUTABLE_WINDOW_TYPES = new Set([
Meta.WindowType.NORMAL,
Meta.WindowType.DIALOG,
Meta.WindowType.MODAL_DIALOG,
]);
+
const WINDOW_RULES = [
{
name: 'main-frame',
@@ -116,6 +140,10 @@ function getWindowInfo(window, windowTracker) {
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(),
};
}
@@ -147,6 +175,26 @@ function resolveMonitor(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();
@@ -163,56 +211,222 @@ export default class WorkspaceRouterExtension extends Extension {
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);
- // User-mode extensions are disabled while the screen is locked and
- // re-enabled before the monitor layout has necessarily settled. Do
- // not treat existing windows as newly created here: doing so can move
- // them to the laptop while it is temporarily the primary monitor.
- // Existing windows can still be routed explicitly through the CLI.
+ 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 [window, sourceId] of this._pendingEvaluations) {
+ for (const sourceId of this._pendingEvaluations.values())
+ GLib.source_remove(sourceId);
+ for (const sourceId of this._windowStateSourceIds.values())
GLib.source_remove(sourceId);
- window.disconnectObject(this);
- }
-
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;
}
- _trackWindow(window) {
- if (!window || this._trackedWindows.has(window))
+ 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;
- if (!this._isRoutableWindow(window))
+ 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);
+ }
- this._queueWindow(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() {
@@ -225,51 +439,117 @@ export default class WorkspaceRouterExtension extends Extension {
return;
this._clearPendingEvaluation(window);
-
const sourceId = GLib.timeout_add(
GLib.PRIORITY_DEFAULT,
EVALUATE_DELAY_MS,
() => {
this._pendingEvaluations.delete(window);
- this._routeWindow(window);
+ this._routeTrackedWindow(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))
+ _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._moveWindow(window, rule);
+ this._moveWindowToMonitor(window, resolveMonitor(rule.monitor));
+ this._moveWindowToWorkspace(window, rule.workspace);
this._routedWindows.add(window);
}
- _moveWindow(window, rule) {
- this._ensureWorkspace(rule.workspace);
+ _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));
+ }
- const monitorIndex = resolveMonitor(rule.monitor);
- if (monitorIndex !== null && monitorIndex !== window.get_monitor())
- window.move_to_monitor(monitorIndex);
+ 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() !== rule.workspace)
- window.change_workspace_by_index(rule.workspace, false);
+ 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());
}
@@ -294,6 +574,12 @@ export default class WorkspaceRouterExtension extends Extension {
_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);
diff --git a/.local/share/gnome-shell/extensions/workspace-router@rbenencia.name/metadata.json b/.local/share/gnome-shell/extensions/workspace-router@rbenencia.name/metadata.json
index ecff8cc..263d964 100644
--- a/.local/share/gnome-shell/extensions/workspace-router@rbenencia.name/metadata.json
+++ b/.local/share/gnome-shell/extensions/workspace-router@rbenencia.name/metadata.json
@@ -2,9 +2,9 @@
"uuid": "workspace-router@rbenencia.name",
"extension-id": "workspace-router",
"name": "Workspace Router",
- "description": "Route newly created windows to fixed workspaces based on title, app ID, or WM class.",
+ "description": "Route windows after creation or display-layout changes, with a D-Bus interface for manual control.",
"shell-version": ["46", "47", "48", "49"],
"url": "https://rbenencia.name",
"session-modes": ["user"],
- "version": 1
+ "version": 3
}
nihil fit ex nihilo