From 1b856a6b31b8f8369256612cef6cc242486534a2 Mon Sep 17 00:00:00 2001 From: Raul Benencia Date: Wed, 12 Aug 2026 13:32:16 -0700 Subject: gnome: consolidate workspace-router extension --- .../extension.js | 375 --------------------- .../metadata.json | 10 - .../workspace-router@rbenencia.name/extension.js | 352 +++++++++++++++++-- .../workspace-router@rbenencia.name/metadata.json | 4 +- 4 files changed, 321 insertions(+), 420 deletions(-) delete mode 100644 .local/share/gnome-shell/extensions/workspace-router-cli@rbenencia.name/extension.js delete mode 100644 .local/share/gnome-shell/extensions/workspace-router-cli@rbenencia.name/metadata.json (limited to '.local/share/gnome-shell/extensions') 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 = ` - - - - - - - - - - - - - -`; - -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 = ` + + + + + + + + + + + + + +`; + 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 } -- cgit v1.2.3