/* Emulate X11WindowType * * Copyright (C) 2022 Sundeep Mediratta (smedius@gmail.com) * Copyright (C) 2020 Sergio Costas (rastersoft@gmail.com) * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, version 3 of the License. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ /* global global */ /* exported EmulateX11WindowType */ import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import Clutter from 'gi://Clutter'; import Meta from 'gi://Meta'; import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import * as DND from 'resource:///org/gnome/shell/ui/dnd.js'; import * as AppFavorites from 'resource:///org/gnome/shell/ui/appFavorites.js'; import * as Utils from 'resource:///org/gnome/shell/misc/util.js'; export {EmulateX11WindowType}; const appID = 'com.desktop.ding'; const appPath = GLib.build_filenamev(['/', ...appID.split('.')]); class ManageWindow { /* This class is added to each managed window, and it's used to make it behave like an X11 Desktop window. Trusted windows will set in the title the characters @!, followed by the coordinates where to put the window separated by a colon, and ended in a semicolon. After that, it can have one or more of these letters: * B : put and always keep this window at the bottom of the stack of windows on screen * T : put and always keep this window at the top of the stack of windows on the screen * D : show this window in all desktops * H : hide this window from the window list Using the title is generally not a problem because the desktop windows do not have a title. But some other windows may have and still need to set a title and use this class, so adding a single blank space at the end of the title is equivalent to @!H, and having two blank spaces at the end of the title is equivalent to @!HTD. This allows use of these flags for decorated or titled windows. */ constructor(window, waylandClient, changedStatusCB) { this.isWayland = typeof Meta.is_wayland_compositor === 'function' ? Meta.is_wayland_compositor() : true; this._isX11 = !this.isWayland; this._waylandClient = waylandClient; this._window = window; this._signalIDs = []; this._onIdleChangedStatusCallback = changedStatusCB; this._titleID = this._window.connect('notify::title', () => { this.refreshProperties(); }); this._parseTitle(); this._attachControllers(); } disconnect() { this._disconnetSignalsAndTimeouts(); if (this._titleID) this._window.disconnect(this._titleID); this._titleID = 0; if (this._keepAtTop) this._window.unmake_above(); this._window = null; this._waylandClient = null; } _disconnetSignalsAndTimeouts() { for (let signalID of this._signalIDs) { if (signalID) this._window.disconnect(signalID); } this._signalIDs = []; if (this._checkOnAllWorkspacesID) GLib.source_remove(this._checkOnAllWorkspacesID); this._checkOnAllWorkspacesID = 0; if (this._moveIntoPlaceID) GLib.source_remove(this._moveIntoPlaceID); this._moveIntoPlaceID = 0; if (this._restackedBottomID) global.display.disconnect(this._restackedBottomID); this._restackedBottomID = 0; if (this._showDesktopID) global.workspace_manager.disconnect(this._showDesktopID); this._showDesktopID = 0; if (this._restackedTopID) global.display.disconnect(this._restackedTopID); this._restackedTopID = 0; } set_wayland_client(client) { this._waylandClient = client; } _parseTitle() { this._x = null; this._y = null; this._keepAtBottom = false; this._keepAtTop = false; this._showInAllDesktops = false; this._hideFromWindowList = false; this._fixed = false; this._desktopWindow = false; let title = this._window.get_title(); if (!title && !!this._window.get_transient_for()) { // Transient dialog window // Does not have title, hide from windowlist title = '@!H'; } if (title !== null) { if ((title.length > 0) && (title[title.length - 1] === ' ')) { if ((title.length > 1) && (title[title.length - 2] === ' ')) title = '@!HTD'; else title = '@!H'; } let pos = title.search('@!'); if (pos !== -1) { let pos2 = title.search(';', pos); let coords; if (pos2 !== -1) coords = title.substring(pos + 2, pos2).trim().split(','); else coords = title.substring(pos + 2).trim().split(','); try { this._x = parseInt(coords[0]); this._y = parseInt(coords[1]); } catch (e) { global.log(`Exception ${e.message}.\n${e.stack}`); } try { let extraChars = title.substring(pos + 2).trim().toUpperCase(); for (let char of extraChars) { switch (char) { case 'B': this._keepAtBottom = true; this._keepAtTop = false; break; case 'T': this._keepAtTop = true; this._keepAtBottom = false; break; case 'D': this._showInAllDesktops = true; break; case 'H': this._hideFromWindowList = true; break; case 'F': this._fixed = true; break; } } this._desktopWindow = this._keepAtBottom && !this._keepAtTop && this._showInAllDesktops && this._hideFromWindowList; } catch (e) { global.log(`Exception ${e.message}.\n${e.stack}`); } } } } _attachControllers() { if (this._fixed) this._keepFixedWindowPosition(); if (this._hideFromWindowList) this._keepWindowHidden(); else this._unhideWindow(); if (this._keepAtTop) this._keepWindowOnTop(); else if (this._window.above) this._window.unmake_above(); if (this._keepAtBottom & !this._desktopWindow) this._keepWindowAtBottom(); if (this._showInAllDesktops & !this._desktopWindow) this._showWindowOnAllDesktops(); else if (this._window.on_all_workspaces) this._window.unstick(); if (this._desktopWindow) { if (typeof this._window.set_type === 'function') { this._window.set_type(Meta.WindowType.DESKTOP); console.log('Setting window type to desktop with Gnome 49 API'); // In future, Meta.WaylandClient.make_desktop(window) will not // be necessary. } else { this._makeWindowTypeDesktop(); } } } _keepFixedWindowPosition() { this._signalIDs.push( this._window.connect( 'position-changed', () => { if (this._fixed && (this._x !== null) && (this._y !== null) ) { this._window.move_frame(true, this._x, this._y); if (this._window.fullscreen) this._window.unmake_fullscreen(); } } ) ); this._signalIDs.push( this._window.connect('notify::minimized', () => { this._window.unminimize(); }) ); this._signalIDs.push( this._window.connect('notify::maximized-vertically', () => { if (typeof this._window.is_maximized === 'function' && !this._window.is_maximized() ) this._window.maximize(); else if (!this._window.maximized_vertically) this._window.maximize(Meta.MaximizeFlags.VERTICAL); this._moveIntoPlace(); } ) ); this._signalIDs.push( this._window.connect('notify::maximized-horizontally', () => { if (typeof this._window.is_maximized === 'function' && !this._window.is_maximized() ) this._window.maximize(); else if (!this._window.maximized_horizontally) this._window.maximize(Meta.MaximizeFlags.HORIZONTAL); this._moveIntoPlace(); } ) ); if ((this._x !== null) && (this._y !== null)) this._window.move_frame(true, this._x, this._y); } _moveIntoPlace() { if (this._moveIntoPlaceID) GLib.source_remove(this._moveIntoPlaceID); this._moveIntoPlaceID = GLib.timeout_add(GLib.PRIORITY_LOW, 250, () => { if (this._fixed && (this._x !== null) && (this._y !== null)) this._window.move_frame(true, this._x, this._y); this._moveIntoPlaceID = 0; return GLib.SOURCE_REMOVE; }); } _keepWindowHidden() { if (!this._isX11 && this._waylandClient) { this._waylandClient.hide_from_window_list(this._window); } else { const xid = this._window.xwindow; this._setX11windowSkipTaskbar(xid); } } _unhideWindow() { if (!this._isX11 && this._waylandClient) { this._waylandClient.show_in_window_list(this._window); } else { const xid = this._window.xwindow; this._unSetX11windowSkipTaskbar(xid); } } _keepWindowAtBottom() { this._signalIDs.push( this._window.connect( 'notify::above', () => { if (this._keepAtBottom && this._window.above) this._window.unmake_above(); } ) ); this._signalIDs.push( this._window.connect_after( 'raised', () => { if (this._keepAtBottom) this._window.lower(); } ) ); /* If a window is lowered below us with shortcuts, detect and fix DING window */ this._restackedBottomID = global.display.connect('restacked', this._syncToBottomOfStack.bind(this) ); /* If the desktop is shown with keyboard gnome shortcuts, detect and put DING window back. Seems to be needed for X11, works without on Wayland. */ if (this._isX11) { this._showDesktopID = global.workspace_manager.connect( 'showing-desktop-changed', this._activateDesktopWindow.bind(this) ); } if (this._window.above) this._window.unmake_above(); this._window.lower(); } _keepWindowUnFullScreen() { this._signalIDs.push( this._window.connect( 'notify::fullscreen', () => { if (this._window.fullscreen) this._window.unmake_fullscreen(); } ) ); if (this._window.fullscreen) this._window.unmake_fullscreen(); } _activateDesktopWindow() { if (this._desktopWindow) this._window.activate(Meta.CURRENT_TIME); } _syncToBottomOfStack() { let windows = global.display .get_tab_list( Meta.TabList.NORMAL_ALL, global.workspace_manager.get_active_workspace() ); windows = global.display.sort_windows_by_stacking(windows); if (windows.length > 1 && !windows[0].customJS_ding) this._moveDesktopWindowToBottom(); } _moveDesktopWindowToBottom() { if (this._window.fullscreen) this._window.unmake_fullscreen(); if (this._keepAtBottom) this._window.lower(); } _keepWindowOnTop() { this._restackedTopID = global.display.connect('restacked', () => { if (!this._window.above) this._window.make_above(); }); if (!this._window.above) this._window.make_above(); } _showWindowOnAllDesktops() { this._signalIDs.push(this._window.connect('notify::on-all-workspaces', this._checkOnAllWorkspaces.bind(this) )); this._signalIDs.push(this._window.connect('workspace-changed', this._checkOnAllWorkspaces.bind(this) )); this._window.stick(); } _checkOnAllWorkspaces() { if (this._checkOnAllWorkspacesID) GLib.source_remove(this._checkOnAllWorkspacesID); this._checkOnAllWorkspacesID = GLib.idle_add( GLib.PRIORITY_LOW, () => { if (this._showInAllDesktops && !this._window.on_all_workspaces ) { this._window.stick(); this._onIdleActivateTopWindowOnActiveWorkspace(); } this._checkOnAllWorkspacesID = null; return GLib.SOURCE_REMOVE; } ); } _makeWindowTypeDesktop() { if (!this._isX11 && this._waylandClient) { const desktopWindowTypeSetOnWindow = this._waylandClient.make_desktop_window(this._window); if (!desktopWindowTypeSetOnWindow) { this._emulateDesktopWindow(); return; } } else { const xid = this._window.xwindow; try { this._setX11windowTypeDesktop(xid); } catch (e) { logError(e); this._emulateDesktopWindow(); return; } } // Window manager bug - it treats request to resize window // to monitor size as a fullscreen window request as well and makes // the window fullscreen, more so for legacy X11 apps. // This makes intellihide for docks/panels hide from desktop window this._keepWindowUnFullScreen(); const activateTopWindowOnWorkspace = true; this._onIdleChangedStatusCallback({activateTopWindowOnWorkspace}); } _emulateDesktopWindow() { console.log('Emulating window type Desktop'); this._window.get_window_type = function () { return Meta.WindowType.DESKTOP; }; this._keepWindowAtBottom(); this._showWindowOnAllDesktops(); const moveDesktopWindowToBottom = true; const activateTopWindowOnWorkspace = true; this._onIdleChangedStatusCallback( {moveDesktopWindowToBottom, activateTopWindowOnWorkspace} ); } _onIdleActivateTopWindowOnActiveWorkspace() { const activateTopWindowOnWorkspace = true; this._onIdleChangedStatusCallback({activateTopWindowOnWorkspace}); } _setX11windowSkipTaskbar(xid) { // Unfortunately xprop can set only one of the properties in the state, // not multiple. // Stick to setting only skip-taskbar, we can otherwirse also set // the property for pager, _NET_WM_STATE_SKIP_PAGER const commandline = `xprop -id ${xid}` + ' -f _NET_WM_STATE 32a' + ' -set _NET_WM_STATE' + ' _NET_WM_STATE_SKIP_TASKBAR'; console.log('Making X11 windowtype type skip-taskbar'); Utils.spawnCommandLine(commandline); } _unSetX11windowSkipTaskbar(xid) { const commandline = `xprop -id ${xid}` + ' -f _NET_WM_STATE 32a' + ' -remove _NET_WM_STATE' + ' _NET_WM_STATE_SKIP_TASKBAR'; console.log('Making X11 windowtype type NOT skip-taskbar'); Utils.spawnCommandLine(commandline); } _setX11windowTypeDesktop(xid) { const commandline = `xprop -id ${xid}` + ' -f _NET_WM_WINDOW_TYPE 32a' + ' -set _NET_WM_WINDOW_TYPE' + ' _NET_WM_WINDOW_TYPE_DESKTOP'; console.log('Making X11 windowtype type Desktop'); Utils.trySpawnCommandLine(commandline); } refreshProperties() { this._disconnetSignalsAndTimeouts(); this._parseTitle(); this._attachControllers(); } get hideFromWindowList() { return this._hideFromWindowList; } get keepAtBottom() { return this._keepAtBottom; } get desktopWindow() { return this._desktopWindow; } } var EmulateX11WindowType = class { /* This class does all the heavy lifting for emulating WindowType. Just make one instance of it, call enable(), and whenever a window that you want to give "superpowers" is mapped, add it with the "addWindowManagedCustomJS_ding" method. That's all. */ constructor() { this._windowList = new Set(); this._overviewHiding = true; this._waylandClient = null; this.isWayland = typeof Meta.is_wayland_compositor === 'function' ? Meta.is_wayland_compositor() : true; this._isX11 = !this.isWayland; } set_wayland_client(client) { this._waylandClient = client; for (let window of this._windowList) { if (window.customJS_ding) window.customJS_ding.set_wayland_client(this._waylandClient); } } enable() { this._idMap = global.window_manager.connect_after( 'map', (obj, windowActor) => { const window = windowActor.get_meta_window(); if (window.get_window_type() > Meta.WindowType.MODAL_DIALOG) return; const appid = window.get_gtk_application_id(); if (appid !== appID) return; const windowpid = window.get_pid(); const mypid = this._waylandClient ? parseInt(this._waylandClient.query_pid_of_program()) : null; if (this._waylandClient && this._waylandClient.query_window_belongs_to(window) ) { this._addWindowManagedCustomJS_ding( window, windowActor ); return; } if (mypid !== null && windowpid === mypid) { this._addWindowManagedCustomJS_ding( window, windowActor ); } } ); /* But in Overview mode it is paramount to not change the workspace to emulate "stick", or the windows will appear */ this._showingId = Main.overview.connect('showing', () => { this._overviewHiding = false; }); this._hidingId = Main.overview.connect('hiding', () => { this._overviewHiding = true; }); } disable() { if (this._activate_window_ID) { GLib.source_remove(this._activate_window_ID); this._activate_window_ID = null; } for (let window of this._windowList) this._clearWindow(window); this._windowList.clear(); // disconnect signals if (this._idMap) { global.window_manager.disconnect(this._idMap); this._idMap = null; } if (this._idDestroy) { global.window_manager.disconnect(this._idDestroy); this._idDestroy = null; } if (this._showingId) { Main.overview.disconnect(this._showingId); this._showingId = null; } if (this._hidingId) { Main.overview.disconnect(this._hidingId); this._hidingId = null; } } _addWindowManagedCustomJS_ding(window, windowActor) { if (window.get_meta_window) { // it is a MetaWindowActor window = window.get_meta_window(); } if (this._windowList.has(window)) return; window.customJS_ding = new ManageWindow( window, this._waylandClient, this.onIdleReStackActivteWindows.bind(this) ); window.actor = windowActor; windowActor._delegate = new HandleDragActors(windowActor); this._windowList.add(window); window.customJS_ding.unmanagedID = window.connect( 'unmanaging', win => { this._clearWindow(win); this._windowList.delete(window); } ); } _clearWindow(window) { window.disconnect(window.customJS_ding.unmanagedID); window.customJS_ding.disconnect(); window.customJS_ding = null; window.actor._delegate = null; window.actor = null; } _activateTopWindowOnActiveWorkspace() { let windows = global.display .get_tab_list( Meta.TabList.NORMAL, global.workspace_manager.get_active_workspace() ); windows = global.display.sort_windows_by_stacking(windows); if (windows.length) { const topWindow = windows[windows.length - 1]; topWindow.focus(Clutter.CURRENT_TIME); } } _moveDesktopWindowToBottom() { for (let window of this._windowList) window.customJS_ding._moveDesktopWindowToBottom(); } onIdleReStackActivteWindows(action = {activateTopWindowOnWorkspace: true}) { if (!this._activate_window_ID) { this._activate_window_ID = GLib.idle_add( GLib.PRIORITY_LOW, () => { if (this._overviewHiding) { if (action.moveDesktopWindowToBottom) this._moveDesktopWindowToBottom(); if (action.activateTopWindowOnWorkspace) this._activateTopWindowOnActiveWorkspace(); } this._activate_window_ID = null; return GLib.SOURCE_REMOVE; } ); } } // After shell unlock, window seems to lose stick property, // refresh window properties refreshWindows() { for (let window of this._windowList) window.customJS_ding.refreshProperties(); } }; // Since Gnome Shell 48 the enumeration of the cursor is different // the name has changed, althugh the value is the same; // We use our own enumeration names to avoid problems with the version // of the Gnome Shell, the enumeration integer points to the correct // value in the Gnome Shell 48 and Meta 48 Enum and earlier. // Using the wrong Enum Name seems to crash mutter const ShellDropCursor = { DEFAULT: 2, // META_CURSOR_DEFAULT Meta.Cursor.DEFAULT NODROP: 15, // META_CURSOR_NO_DROP Meta.Cursor.DND_UNSUPPORTED_TARGET COPY: 13, // META_CURSOR_COPY Meta.Cursor.DND_COPY MOVE: 14, // META_CURSOR_MOVE Meta.Cursor.DND_MOVE }; class HandleDragActors { /* This class is added to each managed windowActor, and it's used to make it behave like a shell Actor that can accept drops from Gnome Shell dnd. */ constructor(windowActor) { this.windowActor = windowActor; this.remoteDingActions = Gio.DBusActionGroup.get( Gio.DBus.session, appID, appPath ); } _getModifierKeys() { let [, , state] = global.get_pointer(); state &= Clutter.ModifierType.MODIFIER_MASK; this.isControl = (state & Clutter.ModifierType.CONTROL_MASK) !== 0; this.isShift = (state & Clutter.ModifierType.SHIFT_MASK) !== 0; } handleDragOver(source) { if ((source.app ?? null) === null) return DND.DragMotionResult.NO_DROP; this._getModifierKeys(); if (this.isShift) { global.display.set_cursor(ShellDropCursor.COPY); return DND.DragMotionResult.COPY_DROP; } if (this.isControl) { global.display.set_cursor(ShellDropCursor.MOVE); return DND.DragMotionResult.MOVE_DROP; } return DND.DragMotionResult.CONTINUE; } acceptDrop(source, actor, x, y) { if ((source.app ?? null) === null) return false; let appFavorites = AppFavorites.getAppFavorites(); let sourceAppId = source.app.get_id(); let sourceAppPath = source.app.appInfo.get_filename(); let appIsFavorite = appFavorites.isFavorite(sourceAppId); this._getModifierKeys(); if (appIsFavorite && !this.isShift) appFavorites.removeFavorite(sourceAppId); if (sourceAppPath && (this.isControl || this.isShift)) { this.remoteDingActions.activate_action('createDesktopShortcut', new GLib.Variant('a{sv}', { uri: GLib.Variant.new_string(`file://${sourceAppPath}`), X: new GLib.Variant('i', parseInt(x)), Y: new GLib.Variant('i', parseInt(y)), }) ); } appFavorites.emit('changed'); return true; } }