├── .gitignore ├── LICENSE ├── README.md ├── closeSession.js ├── constants.js ├── dbus-interfaces ├── org.freedesktop.login1.Manager.xml ├── org.gnome.SessionManager.EndSessionDialog.xml ├── org.gnome.Shell.Extensions.awsm.Autostart.xml └── org.gnome.Shell.Extensions.awsm.PickWindow.xml ├── extension.js ├── icons ├── autorestore-symbolic.svg ├── choose-window-symbolic.svg ├── close-symbolic.svg ├── empty-symbolic.svg ├── move-symbolic.svg ├── readme.md ├── restore-symbolic.svg ├── save-symbolic.svg ├── separator-symbolic.svg ├── toggle-off-autorestore-symbolic.svg └── toggle-on-autorestore-symbolic.svg ├── indicator.js ├── metadata.json ├── model ├── closeWindowsRule.js └── sessionConfig.js ├── moveSession.js ├── openWindowsTracker.js ├── prefs.js ├── prefsCloseWindow.js ├── prefsColumnView.js ├── prefsWidgets.js ├── prefsWindowPickableEntry.js ├── restoreSession.js ├── saveSession.js ├── schemas ├── gschemas.compiled └── org.gnome.shell.extensions.another-window-session-manager.gschema.xml ├── stylesheet.css ├── template ├── 60-awsm-ydotool-uinput.rules ├── _awsm-restore-previous-session.desktop ├── _gnome-shell-extension-another-window-session-manager.desktop ├── launch-app.sh └── template.desktop ├── ui ├── autoclose.js ├── autostart.js ├── button.js ├── popupMenuButtonItems.js ├── prefs-gtk4.ui ├── searchSessionItem.js ├── sessionItem.js ├── sessionItemButtons.js └── uiHelper.js ├── utils ├── CommonError.js ├── WindowPicker.js ├── dateUtils.js ├── fileUtils.js ├── function.js ├── iconFinder.js ├── log.js ├── metaWindowUtils.js ├── prefsUtils.js ├── signal.js ├── stringUtils.js ├── subprocessUtils.js └── tooltip.js └── windowTilingSupport.js /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .history 3 | .idea -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gnome-shell-extension-another-window-session-manager 2 | Close open windows gracefully and save them as a session. And you can restore them when necessary manually or automatically at startup. 3 | 4 | Most importantly, it supports both X11 and Wayland! 5 | 6 | This extension is based on several [Gnome technologies](https://www.gnome.org/technologies/) and APIs including [Meta](https://gjs-docs.gnome.org/meta9~9_api), [Shell](https://gjs-docs.gnome.org/shell01~0.1_api/) and [St(Shell Toolkit)](https://gjs-docs.gnome.org/st10~1.0_api/). 7 | 8 | 9 |

10 | 11 | Get it on GNOME Extensions 12 | 13 |

14 | 15 | # Screenshot 16 | 17 | ## Overview 18 | ![image](https://user-images.githubusercontent.com/2271720/163019716-2177ca8e-97b7-4a6c-9c4a-74a2326642be.png) 19 | 20 | ## Close open windows 21 | Click item to close open windows: 22 | 23 | ![image](https://user-images.githubusercontent.com/2271720/163229388-5504c439-ae4a-445b-a3f7-aa768af3975d.png) 24 | 25 | 26 | After confirm to close: 27 | 28 | ![image](https://user-images.githubusercontent.com/2271720/163229434-2c06b9d2-2b19-4205-80e8-58c2ae68a0cd.png) 29 | 30 | ## Save open windows 31 | Click item to save open windows as a session: 32 | 33 | ![image](https://user-images.githubusercontent.com/2271720/147727121-82cb063f-339d-481c-bccb-07e91e0fe5d4.png) 34 | 35 | 36 | After confirm to save: 37 | 38 | ![image](https://user-images.githubusercontent.com/2271720/163229511-f83df883-5afe-47ae-8855-fef68586e5a4.png) 39 | 40 | ## Activate the current session to be restored at startup 41 | ![image](https://user-images.githubusercontent.com/2271720/162792703-20da002b-b590-4df5-964e-9c586e8915bc.png) 42 | 43 | ## Preferences 44 | 45 | ### Restore sessions 46 | ![image](https://user-images.githubusercontent.com/2271720/214390369-04736886-6dac-48de-bcde-782277a4448e.png) 47 | 48 | ### Close windows 49 | ![image](https://user-images.githubusercontent.com/2271720/215283405-5c052244-8223-4aa4-9786-2798a073c3e0.png) 50 | 51 | # Main features 52 | 1. Restore the previous session at startup. **disabled by default**, to enable it please activate `Restore previous apps and windows at startup` under `Restore sessions`. (See also: [Restore previous apps and windows at startup](https://github.com/nlpsuge/gnome-shell-extension-another-window-session-manager#restore-previous-apps-and-windows-at-startup)). 53 | 1. Save running apps and windows automatically when necessary, this will be used to restore the previous session at startup. 54 | 1. Close running apps and windows automatically before `Log Out`, `Restart`, `Power Off`. **disabled by default**, to enable it please activate `Auto close session` under `Close windows`. (See also: [Auto close session](https://github.com/nlpsuge/gnome-shell-extension-another-window-session-manager#auto-close-session)). 55 | 1. Close running windows gracefully 56 | 1. Close apps with multiple windows gracefully via `ydotool` so you don't lose sessions of this app (See also: [How to make Close by rules work](https://github.com/nlpsuge/gnome-shell-extension-another-window-session-manager#how-to-make-close-by-rules-work)) 57 | 1. Save running apps and windows manually 58 | 1. Restore a selected session at startup (See also: [#9](https://github.com/nlpsuge/gnome-shell-extension-another-window-session-manager/issues/9#issuecomment-1097012874)). **disabled by default**. 59 | 1. Restore a saved session manually 60 | 1. Restore window state, including `Always on Top`, `Always on Visible Workspace` and maximization 61 | 1. Restore window workspace, size and position 62 | 1. Restore 2 column window tiling 63 | 1. Stash all supported window states so that those states will be restored after gnome shell restarts via `Alt+F2 -> r` or `killall -3 gnome-shell`. 64 | 1. Move windows to their own workspace according to a saved session 65 | 1. Support multi-monitor 66 | 1. Remove saved session to trash 67 | 1. Search saved session by the session name fuzzily 68 | 1. ... 69 | 70 | ## Close windows 71 | 72 | ### Auto close session 73 | Enable this feature through `Auto close session` under `Close windows`: 74 | 75 | ![image](https://user-images.githubusercontent.com/2271720/214387813-fece3c78-6e27-494a-9edd-4705350c7179.png) 76 | 77 | After you click the `Log Out/Restart/Power Off` button: 78 | 79 | ![image](https://user-images.githubusercontent.com/2271720/214377307-0af5b841-93b8-4b6c-bd09-7a620dc79025.png) 80 | 81 | If the second button on the above dialog has `via AWSM`, it means this feature is enabled. 82 | 83 | After you click `Log Out(via AWSM)`, all apps and windows will be closed automatically by AWSM. But some apps might be still opening, you have to close them yourself; then if there are no running apps, this extension logs out the current user immediately. 84 | 85 | ![image](https://user-images.githubusercontent.com/2271720/214394659-651e6259-842c-49ca-9c97-6df62c9485d1.png) 86 | 87 | You can move it around in case it covers other windows. 88 | 89 | Please note that currently if this option is enabled, it modifies the Gnome Shell `endSessionDialog` **globally**, which means running `gnome-session-quit --logout` will also popup the new modified dialog. 90 | 91 | ### How to make `Close by rules` work 92 | 93 | To make this feature work, you need to install [ydotool](https://github.com/ReimuNotMoe/ydotool): 94 | 95 | ```bash 96 | # 1. Install `ydotool` using the package manager and make sure the version is greater than v1.0.0 97 | sudo dnf install ydotool 98 | #Or install it from the source code: https://github.com/ReimuNotMoe/ydotool 99 | 100 | #Check the permission of `/dev/uinput`, if it's `crw-rw----+`, you can skip step 2 101 | # 2. Get permission to access to `/dev/uinput` as the normal user 102 | sudo touch /etc/udev/rules.d/60-awsm-ydotool-uinput.rules 103 | # Here we use `tee`, not redirect(>), to avoid `warning: An error occurred while redirecting file '/etc/udev/rules.d/60-awsm-ydotool-uinput.rules' open: Permission denied` 104 | # See: https://www.shellhacks.com/sudo-echo-to-file-permission-denied/ 105 | echo '# See: 106 | # https://github.com/ValveSoftware/steam-devices/blob/master/60-steam-input.rules 107 | # https://github.com/ReimuNotMoe/ydotool/issues/25 108 | 109 | # ydotool udev write access 110 | KERNEL=="uinput", SUBSYSTEM=="misc", TAG+="uaccess", OPTIONS+="static_node=uinput"' | sudo tee --append /etc/udev/rules.d/60-awsm-ydotool-uinput.rules 111 | 112 | cat /etc/udev/rules.d/60-awsm-ydotool-uinput.rules 113 | #Remove executable permission (a.k.a. x) 114 | sudo chmod 644 /etc/udev/rules.d/60-awsm-ydotool-uinput.rules 115 | 116 | # 3. Copy ydotool.service to /usr/lib/systemd/user, so `systemctl --user enable ydotool.service` can work 117 | sudo cp /usr/lib/systemd/system/ydotool.service /usr/lib/systemd/user 118 | # 4. Start ydotool.service at startup automatically for the current normal user 119 | systemctl --user enable ydotool.service 120 | # 5. Note that you may have to restart the system if the following commands are not working 121 | # 6. Start the ydotoold service for the current normal user 122 | systemctl --user start ydotool.service 123 | # 7. Check if ydotoold service is working. The word `hello` should print on the terminal, if not you might need to reboot the system or try to relogin your account. 124 | ydotool type 'hello' 125 | 126 | ## misc. ## 127 | 128 | # Check if the ydotoold service is running, if not you may have to restart the system or start ydotool.service 129 | systemctl --user status ydotool.service 130 | 131 | ``` 132 | 133 | Note that it's no necessary to run `systemctl --user enable ydotool.service`, because this extension starts `ydotool.service` every time while you use it to close windows. 134 | 135 | Feel free to fill an issue if `ydotool` does not work under normal user, you may also want to do that in [its git issue area](https://github.com/ReimuNotMoe/ydotool/issues) 136 | 137 | ## Restore sessions 138 | 139 | ### Restore previous apps and windows at startup 140 | ![image](https://user-images.githubusercontent.com/2271720/214390369-04736886-6dac-48de-bcde-782277a4448e.png) 141 | 142 | Activate `Restore previous apps and windows at startup` to enable this feature. This option and `Restore selected session at startup` are exclusive. And this option works for shutting down the system normally (via Log Out/Restart/Power Off buttons) and other ways (like pressing the physical power-off button). 143 | 144 | Then while startup, AWSM will launch and restore apps and states from the previous saved session configs. 145 | 146 | The session configs are saved in the path `~/.config/another-window-session-manager/sessions/currentSession`. 147 | 148 | You can use the below command to test it. 149 | ```bash 150 | gdbus call --session --dest org.gnome.Shell.Extensions.awsm --object-path /org/gnome/Shell/Extensions/awsm --method org.gnome.Shell.Extensions.awsm.Autostart.RestorePreviousSession "{'removeAfterRestore': }" 151 | ``` 152 | 153 | ### How to `Restore a session at startup`? 154 | 155 | To make it work, you must enable it through `Restore sessions -> Restore at startup` in the Preferences AND active a session by clicking in the popup menu. 156 | 157 | While you enable it through `Restore sessions -> Restore at startup`, it creates a `_gnome-shell-extension-another-window-session-manager.desktop` under the folder `~/.config/autostart/`. 158 | 159 | Test the settings in command line via: 160 | ```Bash 161 | gdbus call --session --dest org.gnome.Shell.Extensions.awsm --object-path /org/gnome/Shell/Extensions/awsm --method org.gnome.Shell.Extensions.awsm.Autostart.RestoreSession 162 | ``` 163 | 164 | Please do not modify `_gnome-shell-extension-another-window-session-manager.desktop`, all changes by yourself could be overidden or deleted. 165 | 166 | # Panel menu items 167 | 168 | ## Icons description 169 | 170 | | Icon | Description | 171 | |--------------------------------------------------------------|--------------------------------------------------------------| 172 | | | Save open windows as a session, which name is the item's name | 173 | | | Restore the saved session using the item's name | 174 | | | Move the open windows using the item's name | 175 | | | Close the current open windows | 176 | | | Activate the current session to be restored at startup | 177 | | | Inactivate the current session to be restored at startup | 178 | | | Indicate the autorestore button | 179 | 180 | 181 | # Dependencies 182 | * procps-ng 183 | 184 | Use `ps` and `pwdx` to get some information from a process, install it via `dnf install procps-ng` if you don't have. 185 | 186 | * glib2 187 | 188 | Use `gdbus` to call the remote method, which is provided by this exension, to implement the `restore at start` feature. `gdbus` is part of `glib2`. 189 | 190 | * ydotool 191 | 192 | Send keys to close the application gracefully with multiple windows. 193 | 194 | * libgtop2 195 | 196 | As of version 34, AWSM also uses `libgtop2` to query process information, just like `ps`. The cost of calling `ps` is very high, so I'm planing to remove this entirely. 197 | 198 | To install it: 199 | 200 | * Fedora and derivatives: 201 | `dnf install libgtop2` 202 | 203 | * Debian, Ubuntu, Pop!_OS, and derivatives: 204 | `apt install gir1.2-gtop-2.0 libgtop2-dev` 205 | 206 | * Arch and derivatives: 207 | `pacman -S libgtop` 208 | 209 | # Known issues 210 | 211 | 1. On both X11 and Wayland, if click restore button () continually during the process of restoring, the window size and position may can't be restored, and it may restore many instances of an application. **As a workaround, click the restore button () only once until all apps are restored.** 212 | 213 | # Support applications launched via a command line or applications that don't have a proper .desktop file 214 | If the .desktop is missing from a session file, restoring an application relies on the command line completely. 215 | 216 | In this case this extension will generate a .desktop in the `journalctl` when you click the save button (). Search `Generated a .desktop file` in `journalctl /usr/bin/gnome-shell -r` to find it: `journalctl /usr/bin/gnome-shell -b -o cat --no-pager | grep 'Generated a .desktop file'`. To make it work, You need to copy it to `~/.local/share/applications`, and relaunch the app and save the session again. This extension should be able to restore the workspace, state, size and position of this application. 217 | 218 | **The generated .desktop might not work sometimes, it's better to check whether the value of `Exec` is correct or not.** If you restore an app using a bad .desktop, this extension will give you a notification and log error level logs in the `journalctl`. 219 | 220 | I tested on Anki, VirtualBox machine and two .AppImage apps, they all have no .desktop and are launched in the terminal. By using the generated .desktop, Anki, VirtualBox machine works. One .AppImage app works. Another .AppImage app is `Wire_x86_64.AppImage` and doesn't work, because the command line returned is something like `/tmp/.mount_Wire-3xxxxx/wire-desktop`, you can use it to launch Wire but files in the `/tmp` will be deleted during the OS shutdown and start. 221 | 222 | It's impossible / hard to query the command line from a process, the pid of a window might not be right too and I don't find a standard way for this. 223 | 224 | ## How can I know whether a .desktop of an application is proper or not? 225 | 226 | One of the following should be enough to prove the .desktop is not proper: 227 | 1. Right click on the icon in the panel or dash, if there is no `Add to Favorites` in the menu 228 | 2. This extension can launch an application, but can't move the window to its workspace. (But it might suggest there is a bug in this extension, LOL :)) 229 | 230 | Most existing applications should have a proper .desktop. I'm just handling the special case. Someone like myself might want this feature. 231 | 232 | # Where are the saved sessions? 233 | They are all in `~/.config/another-window-session-manager/sessions`. When use an existing name to save the current open windows, the previous file will be copied to `~/.config/another-window-session-manager/sessions/backups` as a new name, which is the-old-session-name**.backup-current-timestamp**. 234 | 235 | Note that I've marked `backups` as a reserved word, so you can't use it as a session name when saving a session. But you do have the freedom to manually create a file named `backups` in `~/.config/another-window-session-manager/sessions`. But this extension will only backup the session file that you are clicking the save button and you will receive an error log in the `journalctl` and an error notification every time you save an existing session. 236 | 237 | # TODO 238 | 1. - Close open windows 239 | - [ ] Close all windows on the current workspace. (WIP, see https://github.com/nlpsuge/gnome-shell-extension-another-window-session-manager/pull/71) 240 | 1. - Save open windows 241 | - [x] Save open windows 242 | 1. - Restore saved open windows 243 | - [x] Restore saved open windows 244 | - [x] Move to belonging workspace automatically 245 | - [x] Restore window size and position ([issue 17](https://github.com/nlpsuge/gnome-shell-extension-another-window-session-manager/issues/17)) 246 | - [x] Restore window workspace, size and position of applications launched via a command line and don't have a recognizable `.desktop` file by `Shell.AppSystem.get_default().get_running()`. 247 | - [x] Support multi-monitor ([issue 21](https://github.com/nlpsuge/gnome-shell-extension-another-window-session-manager/issues/21)) 248 | 1. - Saved open windows list 249 | - [x] Save open windows button 250 | - [x] Restore button 251 | - [ ] Rename button (double click text to rename?) 252 | - [x] Move button 253 | - [x] Delete button 254 | 1. - [x] Move windows according to a saved session. 255 | 1. - [ ] Settings 256 | - [x] Debugging mode 257 | - [ ] whitelist using for closing application with multiple windows 258 | 1. - [x] Support restoring a saved session at startup ([issue 9](https://github.com/nlpsuge/gnome-shell-extension-another-window-session-manager/issues/9)) 259 | 1. - [x] Support saving and closing windows when Log Out, Power off, Reboot ([issue 9](https://github.com/nlpsuge/gnome-shell-extension-another-window-session-manager/issues/9)) 260 | 1. - [ ] All TODO tags in the projects 261 | 1. - [ ] Translation? 262 | 1. - [ ] A client tool called `awsm-client` (See: [issue 34](https://github.com/nlpsuge/gnome-shell-extension-another-window-session-manager/issues/34)) 263 | 1. - [ ] Fix any typo or grammar errors. 264 | 1. - [ ] Open the Preferences on the popup menu 265 | 1. - [x] Open the session file from the popup menu 266 | 267 | -------------------------------------------------------------------------------- /constants.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * GdkModifierType 5 | * 6 | * See: https://gitlab.gnome.org/GNOME/gtk/blob/d726ecdb5d1ece870585c7be89eb6355b2482544/gdk/gdkenums.h:L74 7 | */ 8 | export const GDK_SHIFT_MASK = 1 << 0; 9 | export const GDK_CONTROL_MASK = 1 << 2; 10 | export const GDK_ALT_MASK = 1 << 3; 11 | 12 | export const GDK_META_MASK = 1 << 28; 13 | 14 | /* prefs settings */ 15 | export const PREFS_SETTING_AUTORESTORE_SESSIONS = 'autorestore-sessions'; 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /dbus-interfaces/org.freedesktop.login1.Manager.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /dbus-interfaces/org.gnome.SessionManager.EndSessionDialog.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /dbus-interfaces/org.gnome.Shell.Extensions.awsm.Autostart.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /dbus-interfaces/org.gnome.Shell.Extensions.awsm.PickWindow.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /extension.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 4 | 5 | import * as OpenWindowsTracker from './openWindowsTracker.js'; 6 | 7 | import * as Indicator from './indicator.js'; 8 | import * as Autostart from './ui/autostart.js'; 9 | import * as Autoclose from './ui/autoclose.js'; 10 | import {WindowTilingSupport} from './windowTilingSupport.js'; 11 | import * as WindowPicker from './utils/WindowPicker.js'; 12 | 13 | import {Extension, gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js'; 14 | 15 | import * as Log from './utils/log.js'; 16 | import * as FileUtils from './utils/fileUtils.js'; 17 | import {prefsUtilsInit, prefsUtilsDestroy} from './utils/prefsUtils.js'; 18 | 19 | 20 | let _indicator; 21 | let _autostartServiceProvider; 22 | let _openWindowsTracker; 23 | let _autoclose; 24 | let _windowPickerServiceProvider; 25 | 26 | export default class AnotherWindowSessionManagerExtension extends Extension { 27 | 28 | constructor(metadata) { 29 | super(metadata); 30 | } 31 | 32 | enable() { 33 | // settings is needed by the initialization of some utils 34 | this._settings = this.getSettings('org.gnome.shell.extensions.another-window-session-manager'); 35 | 36 | this.initUtils(); 37 | 38 | this._settings.connect('changed::show-indicator', () => this.showOrHideIndicator()); 39 | this.showOrHideIndicator(); 40 | 41 | _autostartServiceProvider = new Autostart.AutostartServiceProvider(); 42 | 43 | WindowTilingSupport.initialize(); 44 | 45 | _openWindowsTracker = new OpenWindowsTracker.OpenWindowsTracker(); 46 | _autoclose = new Autoclose.Autoclose(); 47 | 48 | _windowPickerServiceProvider = new WindowPicker.WindowPickerServiceProvider(); 49 | _windowPickerServiceProvider.enable(); 50 | } 51 | 52 | initUtils() { 53 | prefsUtilsInit(this, this._settings); 54 | FileUtils.init(this); 55 | } 56 | 57 | showOrHideIndicator() { 58 | if (this._settings.get_boolean('show-indicator')) { 59 | if (!_indicator) { 60 | _indicator = new Indicator.AwsIndicator(); 61 | Main.panel.addToStatusArea('Another Window Session Manager', _indicator); 62 | } 63 | } else { 64 | this.hideIndicator(); 65 | } 66 | } 67 | 68 | hideIndicator() { 69 | if (_indicator) { 70 | _indicator.destroy(); 71 | _indicator = null; 72 | } 73 | } 74 | 75 | disable() { 76 | 77 | this.hideIndicator(); 78 | 79 | if (_autostartServiceProvider) { 80 | _autostartServiceProvider.disable(); 81 | _autostartServiceProvider = null; 82 | } 83 | 84 | if (_openWindowsTracker) { 85 | _openWindowsTracker.destroy(); 86 | _openWindowsTracker = null; 87 | } 88 | 89 | WindowTilingSupport.destroy(); 90 | 91 | if (_autoclose) { 92 | _autoclose.destroy(); 93 | _autoclose = null; 94 | } 95 | 96 | Log.Log.destroyDefault(); 97 | 98 | if (_windowPickerServiceProvider) { 99 | _windowPickerServiceProvider.destroy(); 100 | _windowPickerServiceProvider = null; 101 | } 102 | 103 | if (this._settings) { 104 | this._settings = null; 105 | } 106 | 107 | prefsUtilsDestroy(); 108 | 109 | } 110 | 111 | } 112 | -------------------------------------------------------------------------------- /icons/autorestore-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 34 | 37 | 48 | 59 | 70 | A 81 | 82 | 83 | -------------------------------------------------------------------------------- /icons/choose-window-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/close-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/empty-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 36 | 39 | 40 | 42 | 46 | 47 | -------------------------------------------------------------------------------- /icons/move-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/readme.md: -------------------------------------------------------------------------------- 1 | [restore-symbolic.svg](How can I get the absolute path from a St.Icon object? I don't find a method to do that in the doc https://gjs-docs.gnome.org/st10~1.0_api/st.icon#method-get_icon_name, any suggestion?) 2 | 3 | [move-symbolic.svg](https://iconduck.com/icons/21634/alternate-arrows) 4 | 5 | [close-symbolic.svg](https://iconduck.com/icons/47700/close) 6 | 7 | [save-symbolic.svg](https://iconduck.com/icons/21869/download) 8 | 9 | [separator-symbolic.svg](https://iconduck.com/icons/59149/separator) 10 | 11 | [toggle-off-symbolic.svg modified based on toggle-off.svg](https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/main/data/theme/toggle-off.svg) 12 | 13 | [toggle-on-symbolic.svg modified based on toggle-on.svg](https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/main/data/theme/toggle-on.svg) 14 | 15 | [choose-window-symbolic.svg](https://iconduck.com/icons/230732/choose-your-profession) 16 | -------------------------------------------------------------------------------- /icons/restore-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/save-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/separator-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/toggle-off-autorestore-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 33 | 36 | 46 | 56 | 66 | 67 | A 78 | 79 | -------------------------------------------------------------------------------- /icons/toggle-on-autorestore-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 33 | 36 | 45 | 55 | 65 | 66 | A 77 | 78 | -------------------------------------------------------------------------------- /metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "_generated": "Generated by SweetTooth, do not edit", 3 | "description": "Close open windows gracefully and save them as a session. And you can restore them when necessary manually or automatically at startup. Most importantly, it supports both X11 and Wayland!\n\nMain features:\n- Restore the previous session at startup. disabled by default.\n- Save running apps and windows automatically when necessary, this will be used to restore the previous session at startup.\n- Close running apps and windows automatically before Log Out, Restart, Power Off. disabled by default.\n- Close running windows gracefully\n- Close apps with multiple windows gracefully via ydotool so you don't lose sessions of this app (See also: How to make Close by rules work)\n- Save running apps and windows manually\n- Restore a selected session at startup (See also: #9). disabled by default.\n- Restore a saved session manually\n- Restore window state, including Always on Top, Always on Visible Workspace and maximization\n- Restore window workspace, size and position\n- Restore 2 column window tiling\n- Stash all supported window states so that those states will be restored after gnome shell restarts via Alt+F2 -> r or killall -3 gnome-shell.\n- Move windows to their own workspace according to a saved session\n- Support multi-monitor\n- Remove saved session to trash\n- Search saved session by the session name fuzzily\n\nFor more information, please visit https://github.com/nlpsuge/gnome-shell-extension-another-window-session-manager/blob/feature-close-save-session-while-logout/README.md.\n\nPlease report issues on Github.", 4 | "name": "Another Window Session Manager", 5 | "shell-version": [ 6 | "45", 7 | "46" 8 | ], 9 | "url": "https://github.com/nlpsuge/gnome-shell-extension-another-window-session-manager", 10 | "uuid": "another-window-session-manager@gmail.com", 11 | "version": 49 12 | } 13 | -------------------------------------------------------------------------------- /model/closeWindowsRule.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import GObject from 'gi://GObject'; 4 | 5 | import * as CloseWindowsRule from './closeWindowsRule.js'; 6 | 7 | 8 | export const CloseWindowsWhitelist = GObject.registerClass({ 9 | }, class CloseWindowsWhitelist extends GObject.Object { 10 | id; // int. just like the id in MySQL. Used to update or delete rows. 11 | name; // string. Can be any string 12 | compareWith; // string. title, wm_class, wm_class_instance, app_name... 13 | method; // string. equals 14 | enabled; // boolean 15 | enableWhenCloseWindows; // boolean 16 | enableWhenLogout; // boolean 17 | 18 | static new(param) { 19 | return Object.assign(new CloseWindowsRule.CloseWindowsWhitelist(), param); 20 | } 21 | }); 22 | 23 | export const CloseWindowsRuleBase = class { 24 | category; // string. Applications, Keywords 25 | type; // string, rule type, such as 'shortcut' 26 | value; // GdkShortcuts, order and the rule pairs, such as "{1: 'Ctrl+Q}'". 27 | // wm_class; // string 28 | // wm_class_instance; // string 29 | enabled; // boolean 30 | keyDelay; // int, for example: `enabydotool key --key-delay 500 29:1 16:1 16:0 29:0` 31 | } 32 | 33 | export const CloseWindowsRuleByKeyword = class extends CloseWindowsRuleBase { 34 | id; // int. just like the id in MySQL. Used to update or delete rows. 35 | keyword; // string. Can be any string 36 | compareWith; // string. title, wm_class, wm_class_instance, app_name... 37 | // enableRegex; // int. 0, 1 38 | method; // string. endsWith, includes, startsWith, equals, regex. 39 | 40 | static new(param) { 41 | return Object.assign(new CloseWindowsRule.CloseWindowsRuleByKeyword(), param); 42 | } 43 | } 44 | 45 | export const CloseWindowsRuleByApp = class extends CloseWindowsRuleBase { 46 | appId; // string, such as 'firefox.desktop' 47 | appDesktopFilePath; // string, such as '/usr/share/applications/firefox.desktop' 48 | appName; // string, such as 'Firefox' 49 | 50 | static new(param) { 51 | return Object.assign(new CloseWindowsRule.CloseWindowsRuleByApp(), param); 52 | } 53 | } 54 | 55 | /** 56 | * See: https://gitlab.gnome.org/GNOME/gtk/blob/d726ecdb5d1ece870585c7be89eb6355b2482544/gdk/gdkenums.h:L73 57 | * See: https://gitlab.gnome.org/GNOME/gtk/blob/1ce79b29e363e585872901424d3b72041b55e3e4/gtk/gtkeventcontrollerkey.c:L203 58 | */ 59 | export const GdkShortcuts = GObject.registerClass({ 60 | }, class GdkShortcuts extends GObject.Object{ 61 | /** 62 | * For example: Ctrl+Q 63 | */ 64 | shortcut; 65 | order; 66 | /** 67 | * the pressed key. 68 | */ 69 | keyval; 70 | /** 71 | * the raw code of the pressed key. 72 | */ 73 | keycode; 74 | /** 75 | * the bitmask, representing the state of modifier keys and pointer buttons. See `GdkModifierType` in Gtk source. 76 | */ 77 | state; 78 | /** 79 | * Indicate the right Ctrl key was pressed 80 | */ 81 | controlRightPressed; 82 | /** 83 | * Indicate the right Shift key was pressed 84 | */ 85 | shiftRightPressed; 86 | 87 | static new(param) { 88 | return Object.assign(new CloseWindowsRule.GdkShortcuts(), param); 89 | } 90 | }); -------------------------------------------------------------------------------- /model/sessionConfig.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | class WindowState { 4 | // If always on visible workspace 5 | is_sticky; // bool 6 | // If always on top 7 | is_above; // bool 8 | 9 | // Additional fields 10 | 11 | // https://gjs-docs.gnome.org/meta9~9_api/meta.window#method-get_maximized 12 | // 0: Not in the maximization mode 13 | // 1: Horizontal - Meta.MaximizeFlags.HORIZONTAL 14 | // 2: Vertical - Meta.MaximizeFlags.VERTICAL 15 | // 3. Both - Meta.MaximizeFlags.BOTH 16 | meta_maximized; 17 | } 18 | 19 | class WindowPosition { 20 | provider; // str 21 | x_offset; // int 22 | y_offset; // int 23 | width; // int 24 | height; // int 25 | } 26 | 27 | class WindowTilingFor { 28 | app_name; // str 29 | // the .desktop file name 30 | desktop_file_id; // str 31 | // The full .desktop file path 32 | desktop_file_id_full_path; // str 33 | window_title; // str 34 | } 35 | 36 | class WindowTiling { 37 | window_tile_for = new WindowTilingFor(); // WindowTilingFor 38 | } 39 | 40 | export const SessionConfigObject = class { 41 | 42 | window_id; // str, hexadecimal on X11, int on Wayland 43 | desktop_number; // int 44 | pid; // int 45 | username; // str 46 | window_position = new WindowPosition(); // WindowPosition 47 | client_machine_name; // str 48 | window_title; // str 49 | 50 | app_name; // str 51 | wm_class; // str 52 | wm_class_instance; // str 53 | 54 | cmd; // list 55 | process_create_time; // str 56 | 57 | window_state = new WindowState(); // WindowState 58 | 59 | windows_count; // int 60 | 61 | cpu_percent; // float 62 | memory_percent; // float 63 | 64 | // Additional fields 65 | 66 | // the .desktop file name 67 | desktop_file_id; // str 68 | // The full .desktop file path 69 | desktop_file_id_full_path; // str 70 | // The index of the monitor that this window is on. 71 | monitor_number; 72 | // TODO Primary monitor can be changed, what if the primary monitor have been changed when restoring apps? The monitor number is the same as saved monitor_number? 73 | is_on_primary_monitor; 74 | 75 | fullscreen; // boolean 76 | minimized; // boolean 77 | 78 | window_tiling; // WindowTiling 79 | 80 | is_focused; // boolean, whether is the currently active window 81 | 82 | compositor_type; // string. X11, Wayland 83 | } 84 | 85 | export const SessionConfig = class { 86 | session_name; // str 87 | session_create_time; // str 88 | backup_time; // str 89 | restore_times; // list = [] 90 | active_workspace_index; // int 91 | n_workspace; // int. the total number of workspaces 92 | // TODO 93 | // https://gjs-docs.gnome.org/meta9~9_api/meta.workspace#method-activate_with_focus 94 | // https://gjs-docs.gnome.org/meta9~9_api/meta.window#method-activate 95 | focused_window; // SessionConfigObject or SessionConfigObject.window_id? 96 | x_session_config_objects = []; // list[SessionConfigObject] 97 | 98 | 99 | /** 100 | * Sort session_config_objects by desktop number 101 | * 102 | */ 103 | sort() { 104 | let x_session_config_objects_copy = this.x_session_config_objects.slice(); 105 | x_session_config_objects_copy.sort((o1, o2) => { 106 | const desktop_number1 = o1.desktop_number; 107 | const desktop_number2 = o2.desktop_number; 108 | 109 | const diff = desktop_number1 - desktop_number2; 110 | if (diff === 0) { 111 | return 0; 112 | } 113 | 114 | if (diff > 0) { 115 | return 1; 116 | } 117 | 118 | if (diff < 0) { 119 | return -1; 120 | } 121 | 122 | }); 123 | return x_session_config_objects_copy; 124 | } 125 | } -------------------------------------------------------------------------------- /prefs.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import GObject from 'gi://GObject'; 4 | import Gio from 'gi://Gio'; 5 | import GLib from 'gi://GLib'; 6 | import Gtk from 'gi://Gtk'; 7 | import GdkWayland from 'gi://GdkWayland'; 8 | import Gdk from 'gi://Gdk'; 9 | 10 | import {ExtensionPreferences, gettext as _} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; 11 | 12 | import * as FileUtils from './utils/fileUtils.js'; 13 | import * as Log from './utils/log.js'; 14 | import {prefsUtilsInit, prefsUtilsDestroy, PrefsUtils} from './utils/prefsUtils.js'; 15 | import * as StringUtils from './utils/stringUtils.js'; 16 | 17 | import * as PrefsCloseWindow from './prefsCloseWindow.js'; 18 | 19 | 20 | export default class AnotherWindowSessionManagerPreferences extends ExtensionPreferences { 21 | fillPreferencesWindow(window) { 22 | window.set_default_size(1000, 800); 23 | 24 | const settings = this.getSettings('org.gnome.shell.extensions.another-window-session-manager'); 25 | 26 | this.initUtils(settings); 27 | 28 | this._log = new Log.Log(); 29 | 30 | this.render_ui(); 31 | new PrefsCloseWindow.UICloseWindows(this._builder).init(); 32 | this._bindSettings(); 33 | 34 | // Set sensitive AFTER this._bindSettings() to make it work 35 | this._setSensitive(); 36 | 37 | this._addPages(window); 38 | window.connect('close-request', () => { 39 | this._destroy(); 40 | }); 41 | } 42 | 43 | initUtils(settings) { 44 | prefsUtilsInit(this, settings); 45 | FileUtils.init(this); 46 | } 47 | 48 | _addPages(window) { 49 | const pages = [ 50 | this._builder.get_object('close_windows_page'), 51 | this._builder.get_object('save_windows_page'), 52 | this._builder.get_object('restore_sessions_page'), 53 | this._builder.get_object('general_page'), 54 | ]; 55 | pages.forEach(page => window.add(page)); 56 | } 57 | 58 | _setSensitive() { 59 | const activeOfRestorePrevious = this.restore_previous_switch.get_active(); 60 | this.restore_previous_delay_spinbutton.set_sensitive(activeOfRestorePrevious); 61 | 62 | const restore_at_startup_switch_state = this.restore_at_startup_switch.get_active(); 63 | this.timer_on_the_autostart_dialog_spinbutton.set_sensitive(restore_at_startup_switch_state); 64 | this.restore_at_startup_without_asking_switch.set_sensitive(restore_at_startup_switch_state); 65 | this.timer_on_the_autostart_dialog_spinbutton.set_sensitive( 66 | restore_at_startup_switch_state && !this.restore_at_startup_without_asking_switch.get_active() 67 | ); 68 | 69 | const display = Gdk.Display.get_default(); 70 | if (display instanceof GdkWayland.WaylandDisplay) { 71 | this.stash_and_restore_states_switch.set_sensitive(false); 72 | } 73 | } 74 | 75 | _bindSettings() { 76 | PrefsUtils.getSettings().bind( 77 | 'debugging-mode', 78 | this.debugging_mode_switch, 79 | 'active', 80 | Gio.SettingsBindFlags.DEFAULT 81 | ); 82 | 83 | PrefsUtils.getSettings().bind( 84 | 'verbose-logging', 85 | this.verbose_logging_switch, 86 | 'active', 87 | Gio.SettingsBindFlags.DEFAULT 88 | ); 89 | 90 | PrefsUtils.getSettings().bind( 91 | 'show-indicator', 92 | this.show_indicator_switch, 93 | 'active', 94 | Gio.SettingsBindFlags.DEFAULT 95 | ); 96 | 97 | PrefsUtils.getSettings().bind( 98 | 'enable-save-session-notification', 99 | this.save_session_notification_switch, 100 | 'active', 101 | Gio.SettingsBindFlags.DEFAULT 102 | ); 103 | 104 | PrefsUtils.getSettings().bind( 105 | 'enable-autorestore-sessions', 106 | this.restore_at_startup_switch, 107 | 'active', 108 | Gio.SettingsBindFlags.DEFAULT 109 | ); 110 | 111 | PrefsUtils.getSettings().bind( 112 | 'enable-restore-previous-session', 113 | this.restore_previous_switch, 114 | 'active', 115 | Gio.SettingsBindFlags.DEFAULT 116 | ); 117 | 118 | PrefsUtils.getSettings().bind( 119 | 'restore-at-startup-without-asking', 120 | this.restore_at_startup_without_asking_switch, 121 | 'active', 122 | Gio.SettingsBindFlags.DEFAULT 123 | ); 124 | 125 | PrefsUtils.getSettings().bind( 126 | 'autorestore-sessions-timer', 127 | this.timer_on_the_autostart_dialog_spinbutton, 128 | 'value', 129 | Gio.SettingsBindFlags.DEFAULT 130 | ); 131 | 132 | PrefsUtils.getSettings().bind( 133 | 'restore-previous-delay', 134 | this.restore_previous_delay_spinbutton, 135 | 'value', 136 | Gio.SettingsBindFlags.DEFAULT 137 | ); 138 | 139 | PrefsUtils.getSettings().bind( 140 | 'restore-session-interval', 141 | this.restore_session_interval_spinbutton, 142 | 'value', 143 | Gio.SettingsBindFlags.DEFAULT 144 | ); 145 | 146 | PrefsUtils.getSettings().bind( 147 | 'autostart-delay', 148 | this.autostart_delay_spinbutton, 149 | 'value', 150 | Gio.SettingsBindFlags.DEFAULT 151 | ); 152 | 153 | PrefsUtils.getSettings().bind( 154 | 'restore-window-tiling', 155 | this.restore_window_tiling_switch, 156 | 'active', 157 | Gio.SettingsBindFlags.DEFAULT 158 | ); 159 | 160 | PrefsUtils.getSettings().bind( 161 | 'raise-windows-together', 162 | this.raise_windows_together_switch, 163 | 'active', 164 | Gio.SettingsBindFlags.DEFAULT 165 | ); 166 | 167 | PrefsUtils.getSettings().bind( 168 | 'stash-and-restore-states', 169 | this.stash_and_restore_states_switch, 170 | 'active', 171 | Gio.SettingsBindFlags.DEFAULT 172 | ); 173 | 174 | PrefsUtils.getSettings().bind( 175 | 'enable-autoclose-session', 176 | this.auto_close_session_switch, 177 | 'active', 178 | Gio.SettingsBindFlags.DEFAULT 179 | ); 180 | 181 | PrefsUtils.getSettings().bind( 182 | 'enable-close-by-rules', 183 | this.close_by_rules_switch, 184 | 'active', 185 | Gio.SettingsBindFlags.DEFAULT 186 | ); 187 | 188 | PrefsUtils.getSettings().connect('changed::enable-autorestore-sessions', (settings) => { 189 | if (PrefsUtils.getSettings().get_boolean('enable-autorestore-sessions')) { 190 | this._installAutostartDesktopFile(FileUtils.desktop_template_path_restore_at_autostart, 191 | FileUtils.autostart_restore_desktop_file_path); 192 | } 193 | }); 194 | 195 | PrefsUtils.getSettings().connect('changed::enable-restore-previous-session', (settings) => { 196 | if (PrefsUtils.getSettings().get_boolean('enable-restore-previous-session')) { 197 | this._installAutostartDesktopFile(FileUtils.desktop_template_path_restore_previous_at_autostart, 198 | FileUtils.autostart_restore_previous_desktop_file_path); 199 | } 200 | }); 201 | 202 | PrefsUtils.getSettings().connect('changed::restore-at-startup-without-asking', (settings) => { 203 | this.timer_on_the_autostart_dialog_spinbutton.set_sensitive( 204 | !PrefsUtils.getSettings().get_boolean('restore-at-startup-without-asking') 205 | ); 206 | }); 207 | 208 | PrefsUtils.getSettings().connect('changed::autostart-delay', (settings) => { 209 | this._installAutostartDesktopFile(FileUtils.desktop_template_path_restore_at_autostart, 210 | FileUtils.autostart_restore_desktop_file_path); 211 | this._installAutostartDesktopFile(FileUtils.desktop_template_path_restore_previous_at_autostart, 212 | FileUtils.autostart_restore_previous_desktop_file_path); 213 | }); 214 | 215 | } 216 | 217 | render_ui() { 218 | this._builder = new Gtk.Builder(); 219 | this._builder.set_scope(new BuilderScope(this)); 220 | this._builder.add_from_file(this.path + '/ui/prefs-gtk4.ui'); 221 | 222 | this.debugging_mode_switch = this._builder.get_object('debugging_mode_switch'); 223 | this.verbose_logging_switch = this._builder.get_object('verbose_logging_switch'); 224 | this.show_indicator_switch = this._builder.get_object('show_indicator_switch'); 225 | 226 | this.save_session_notification_switch = this._builder.get_object('save_session_notification_switch'); 227 | 228 | this.restore_session_interval_spinbutton = this._builder.get_object('restore_session_interval_spinbutton'); 229 | this.timer_on_the_autostart_dialog_spinbutton = this._builder.get_object('timer_on_the_autostart_dialog_spinbutton'); 230 | this.autostart_delay_spinbutton = this._builder.get_object('autostart_delay_spinbutton'); 231 | this.restore_window_tiling_switch = this._builder.get_object('restore_window_tiling_switch'); 232 | this.restore_window_tiling_switch.connect('notify::active', (widget) => { 233 | const active = widget.active; 234 | this.raise_windows_together_switch.set_sensitive(active); 235 | }); 236 | this.raise_windows_together_switch = this._builder.get_object('raise_windows_together_switch'); 237 | this.stash_and_restore_states_switch = this._builder.get_object('stash_and_restore_states_switch'); 238 | 239 | this.restore_previous_delay_spinbutton = this._builder.get_object('restore_previous_delay_spinbutton'); 240 | this.restore_previous_switch = this._builder.get_object('restore_previous_switch'); 241 | this.restore_previous_switch.connect('notify::active', (widget) => { 242 | const active = widget.active; 243 | const activeOfRestoreAtStartup = this.restore_at_startup_switch.get_active(); 244 | if (activeOfRestoreAtStartup) { 245 | this.restore_at_startup_switch.set_active(!active); 246 | } 247 | this.restore_previous_delay_spinbutton.set_sensitive(active); 248 | }); 249 | 250 | this.restore_at_startup_switch = this._builder.get_object('restore_at_startup_switch'); 251 | this.restore_at_startup_switch.connect('notify::active', (widget) => { 252 | const active = widget.active; 253 | this.restore_at_startup_without_asking_switch.set_sensitive(active); 254 | const enableTimerSpinButton = active && !PrefsUtils.getSettings().get_boolean('restore-at-startup-without-asking'); 255 | if (enableTimerSpinButton) { 256 | this.timer_on_the_autostart_dialog_spinbutton.set_sensitive(true); 257 | } else { 258 | this.timer_on_the_autostart_dialog_spinbutton.set_sensitive(false); 259 | } 260 | 261 | const activeOfRestorePrevious = this.restore_previous_switch.get_active(); 262 | if (activeOfRestorePrevious) { 263 | this.restore_previous_switch.set_active(!active); 264 | } 265 | }); 266 | 267 | this.restore_at_startup_without_asking_switch = this._builder.get_object('restore_at_startup_without_asking_switch'); 268 | this.restore_at_startup_without_asking_switch.connect('notify::active', (widget) => { 269 | const active = widget.active; 270 | this.timer_on_the_autostart_dialog_spinbutton.set_sensitive(!active); 271 | }); 272 | 273 | this.close_by_rules_switch = this._builder.get_object('close_by_rules_switch'); 274 | this.auto_close_session_switch = this._builder.get_object('auto_close_session_switch'); 275 | 276 | } 277 | 278 | _installAutostartDesktopFile(desktopFileTemplate, targetDesktopFilePath) { 279 | const argument = { 280 | autostartDelay: PrefsUtils.getSettings().get_int('autostart-delay'), 281 | }; 282 | const desktopFileContent = StringUtils.format(FileUtils.loadTemplate(desktopFileTemplate), argument); 283 | this._installDesktopFileToAutostartDir(targetDesktopFilePath, desktopFileContent); 284 | } 285 | 286 | _installDesktopFileToAutostartDir(desktopFilePath, desktopFileContents) { 287 | const autostart_restore_desktop_file = Gio.File.new_for_path(desktopFilePath); 288 | const autostart_restore_desktop_file_path_parent = autostart_restore_desktop_file.get_parent().get_path(); 289 | if (GLib.mkdir_with_parents(autostart_restore_desktop_file_path_parent, 0o744) === 0) { 290 | let [success, tag] = autostart_restore_desktop_file.replace_contents( 291 | desktopFileContents, 292 | null, 293 | false, 294 | Gio.FileCreateFlags.REPLACE_DESTINATION, 295 | null 296 | ); 297 | 298 | if (success) { 299 | this._log.info(`Installed the autostart desktop file: ${desktopFilePath}!`); 300 | } else { 301 | this._log.error(new Error(`Failed to install the autostart desktop file: ${desktopFilePath}`)) 302 | } 303 | } else { 304 | this._log.error(new Error(`Failed to create folder: ${autostart_restore_desktop_file_path_parent}`)); 305 | } 306 | } 307 | 308 | _destroy() { 309 | prefsUtilsDestroy(); 310 | 311 | } 312 | } 313 | 314 | 315 | const BuilderScope = GObject.registerClass({ 316 | // Should be a globally unique GType name 317 | GTypeName: "AnotherWindowSessionManagerBuilderScope", 318 | Implements: [Gtk.BuilderScope], 319 | }, class BuilderScope extends GObject.Object { 320 | _init(preferences) { 321 | this._preferences = preferences; 322 | super._init(); 323 | } 324 | 325 | // Fix: Gtk.BuilderError: Creating closures is not supported by Gjs_BuilderScope 326 | // https://docs.w3cub.com/gtk~4.0/gtkbuilder#gtk-builder-create-closure 327 | vfunc_create_closure(builder, handlerName, flags, connectObject) { 328 | if (flags & Gtk.BuilderClosureFlags.SWAPPED) 329 | throw new Error('Unsupported template signal flag "swapped"'); 330 | 331 | if (typeof this[handlerName] === 'undefined') 332 | throw new Error(`${handlerName} is undefined`); 333 | 334 | return this[handlerName].bind(connectObject || this); 335 | } 336 | 337 | }); 338 | -------------------------------------------------------------------------------- /prefsColumnView.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import GObject from 'gi://GObject'; 4 | import Gtk from 'gi://Gtk'; 5 | import Gio from 'gi://Gio'; 6 | 7 | import * as PrefsWindowPickableEntry from './prefsWindowPickableEntry.js'; 8 | import * as PrefsWidgets from './prefsWidgets.js'; 9 | 10 | import {PrefsUtils} from './utils/prefsUtils.js'; 11 | 12 | 13 | export const ColumnView = GObject.registerClass({ 14 | Signals: { 15 | 'activate': { 16 | param_types: [Gtk.CheckButton, GObject.TYPE_OBJECT] 17 | }, 18 | 'row-deleted': { 19 | param_types: [GObject.TYPE_OBJECT] 20 | }, 21 | }, 22 | }, class ColumnView extends Gtk.Box { 23 | 24 | _init(datalist, params = {}) { 25 | super._init({ 26 | orientation: Gtk.Orientation.VERTICAL 27 | }); 28 | 29 | datalist = datalist ? datalist : []; 30 | this.datalist = datalist; 31 | 32 | this._initUI(); 33 | this.updateView(datalist); 34 | } 35 | 36 | _initUI() { 37 | this.model = new Gio.ListStore({ item_type: GObject.TYPE_OBJECT }); 38 | this.selectionModel = new Gtk.SingleSelection({ model: this.model }); 39 | 40 | this.view = new Gtk.ColumnView({ 41 | css_classes: ['view'], 42 | // I feel it's ugly to set this to true 43 | // show_column_separators: true 44 | }); 45 | this.view.set_model(this.selectionModel); 46 | 47 | const enabledColumn = PrefsWidgets.newColumnViewColumn('Enabled', 48 | (factory, listItem) => { 49 | const checkButton = new Gtk.CheckButton() 50 | listItem.set_child(checkButton); 51 | }, (factory, listItem) => { 52 | const widget = listItem.get_child(); 53 | // item is the CloseWindowsWhitelist instance that is added into the model 54 | const item = listItem.get_item(); 55 | // So we can get `enabled` value from `item` here 56 | const enabled = item.enabled 57 | widget.set_active(enabled); 58 | widget.connect('notify::active', () => { 59 | this.emit('activate', widget, item); 60 | }); 61 | }); 62 | 63 | const operationColumn = PrefsWidgets.newColumnViewColumn('Operation', 64 | (factory, listItem) => { 65 | const button = PrefsWidgets.newRemoveButton(); 66 | listItem.set_child(button); 67 | button.connect('clicked', () => { 68 | const item = listItem.get_item(); 69 | this.emit('row-deleted', item); 70 | }); 71 | }, null); 72 | 73 | this.view.append_column(enabledColumn); 74 | this.view.append_column(operationColumn); 75 | 76 | // Add the ColumnView to the Box 77 | this.append(this.view); 78 | } 79 | 80 | updateView(dataList) { 81 | this.model.remove_all(); 82 | for(const item of dataList) { 83 | this.model.append(item); 84 | } 85 | } 86 | 87 | updateRow(settingName, keyName, keyValue, propertyName, value) { 88 | const oldCloseWindowsRules = this._settings.get_string(settingName); 89 | let oldCloseWindowsRulesObj = JSON.parse(oldCloseWindowsRules); 90 | const rule = oldCloseWindowsRulesObj[keyValue]; 91 | rule[propertyName] = value; 92 | const newCloseWindowsRules = JSON.stringify(oldCloseWindowsRulesObj); 93 | this._settings.set_string(settingName, newCloseWindowsRules); 94 | } 95 | 96 | }); 97 | 98 | export const WhitelistColumnView = GObject.registerClass({ 99 | Signals: {}, 100 | Properties: {}, 101 | }, class WhitelistColumnView extends ColumnView { 102 | 103 | _init(datalist) { 104 | super._init(datalist, {}); 105 | 106 | const settingKey = 'close-windows-whitelist'; 107 | this._settings = PrefsUtils.getSettings(); 108 | 109 | const nameColumn = PrefsWidgets.newColumnViewColumn('Name', 110 | null, (factory, listItem) => { 111 | const item = listItem.get_item(); 112 | const name = item.name ? item.name : ''; 113 | const nameEntry = new PrefsWindowPickableEntry.WindowPickableEntry({ 114 | text: name, 115 | tooltip_text: name, 116 | pickConditionFunc: (() => { 117 | return 'wm_class'; 118 | }).bind(this) 119 | }); 120 | listItem.set_child(nameEntry); 121 | nameEntry.connect('entry-edit-complete', (source, entry) => { 122 | this.updateRow(settingKey, 'id', item.id, 'name', entry.get_text()); 123 | }); 124 | }); 125 | 126 | const closeWindowsColumn = PrefsWidgets.newColumnViewColumn('Close windows', 127 | (factory, listItem) => { 128 | const switcher = new Gtk.Switch({halign: Gtk.Align.START, valign: Gtk.Align.CENTER}); 129 | listItem.set_child(switcher); 130 | }, (factory, listItem) => { 131 | const widget = listItem.get_child(); 132 | const item = listItem.get_item(); 133 | const enableWhenCloseWindows = item.enableWhenCloseWindows 134 | widget.set_active(enableWhenCloseWindows); 135 | widget.connect('notify::active', (source) => { 136 | this.updateRow(settingKey, 'id', item.id, 'enableWhenCloseWindows', source.get_active()); 137 | }); 138 | }); 139 | 140 | const logoffColumn = PrefsWidgets.newColumnViewColumn('Log Out, Reboot, Power Off', 141 | (factory, listItem) => { 142 | const switcher = new Gtk.Switch({halign: Gtk.Align.START, valign: Gtk.Align.CENTER}); 143 | listItem.set_child(switcher); 144 | }, (factory, listItem) => { 145 | const widget = listItem.get_child(); 146 | const item = listItem.get_item(); 147 | const enableWhenLogout = item.enableWhenLogout; 148 | widget.set_active(enableWhenLogout); 149 | widget.connect('notify::active', (source) => { 150 | this.updateRow(settingKey, 'id', item.id, 'enableWhenLogout', source.get_active()); 151 | }); 152 | }); 153 | 154 | // The first column is assigned to Enabled column 155 | let index = 1; 156 | this.view.insert_column(index++, nameColumn); 157 | this.view.insert_column(index++, closeWindowsColumn); 158 | this.view.insert_column(index++, logoffColumn); 159 | 160 | this.connect('activate', (source, checkButton, item) => { 161 | const enabled = checkButton.get_active(); 162 | this.updateRow(settingKey, 'id', item.id, 'enabled', enabled); 163 | }); 164 | this.connect('row-deleted', (source, item) => { 165 | const oldCloseWindowsRules = this._settings.get_string(settingKey); 166 | let oldCloseWindowsRulesObj = JSON.parse(oldCloseWindowsRules); 167 | delete oldCloseWindowsRulesObj[item.id]; 168 | const newCloseWindowsRules = JSON.stringify(oldCloseWindowsRulesObj); 169 | this._settings.set_string(settingKey, newCloseWindowsRules); 170 | }); 171 | } 172 | 173 | }); 174 | 175 | -------------------------------------------------------------------------------- /prefsWidgets.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import GObject from 'gi://GObject'; 4 | import Gtk from 'gi://Gtk'; 5 | import GLib from 'gi://GLib'; 6 | 7 | 8 | export const boxProperties = { 9 | spacing: 0, 10 | margin_start: 6, 11 | margin_end: 6, 12 | hexpand: true, 13 | halign: Gtk.Align.START, 14 | margin_top: 0, 15 | margin_bottom: 0, 16 | }; 17 | 18 | export const newColumnViewColumn = function(title, factorySetupFunc, factoryBindFunc) { 19 | const factory = new Gtk.SignalListItemFactory(); 20 | const columnViewColumn = new Gtk.ColumnViewColumn({ 21 | title, 22 | factory 23 | }); 24 | 25 | if (factorySetupFunc) { 26 | factory.connect('setup', (factory, listItem) => { 27 | factorySetupFunc(factory, listItem); 28 | }); 29 | } 30 | 31 | if (factoryBindFunc) { 32 | factory.connect('bind', (factory, listItem) => { 33 | factoryBindFunc(factory, listItem); 34 | }); 35 | } 36 | return columnViewColumn; 37 | } 38 | 39 | export const newRemoveButton = function() { 40 | return new BoxRemoveButton(); 41 | } 42 | 43 | export const newLabelSwitch = function(text, tooltipText, active) { 44 | return new LabelSwitch(text, tooltipText, active); 45 | } 46 | 47 | export const updateStyle = function(widget, css) { 48 | const cssProvider = new Gtk.CssProvider(); 49 | cssProvider.load_from_data(css, -1); 50 | widget.get_style_context().add_provider(cssProvider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION); 51 | } 52 | 53 | export const _newBox = function(properties) { 54 | const box = new Gtk.Box({ 55 | spacing: 6, 56 | margin_top: 6, 57 | margin_bottom: 6, 58 | margin_start: 6, 59 | margin_end: 6, 60 | }) 61 | Object.assign(box, properties); 62 | return box; 63 | } 64 | 65 | export const _newDropDown = function(values, activeValue) { 66 | const dropDownValues = values.map(cv => cv[1]); 67 | const dropDown = Gtk.DropDown.new_from_strings(dropDownValues); 68 | dropDown.set_valign(Gtk.Align.BASELINE); 69 | for (let i = 0; i < dropDownValues.length; i++) { 70 | if (dropDownValues[i] === activeValue) 71 | dropDown.set_selected(i); 72 | } 73 | const factory = dropDown.get_factory(); 74 | factory.connect('bind', (factory, listItem) => { 75 | const box = listItem.get_child(); 76 | const label = box.get_first_child(); 77 | const widthChars = Math.max(...dropDownValues.map( 78 | // GLib.utf8_strlen(v, -1) causes right margin between the label and box is too large, so -2 to reduce this margin 79 | v => GLib.utf8_strlen(v, -1) - 2)); 80 | label.set_width_chars(widthChars); 81 | }); 82 | return dropDown; 83 | } 84 | 85 | export const LabelSwitch = GObject.registerClass({ 86 | Signals: { 87 | 'active': { 88 | param_types: [GObject.TYPE_BOOLEAN, Gtk.Switch] 89 | } 90 | } 91 | }, class LabelSwitch extends Gtk.Box { 92 | 93 | _init(text, tooltipText, active) { 94 | super._init(boxProperties); 95 | this.tooltip_text = tooltipText; 96 | 97 | const [button, switcherBox, switcher] = this._initSwitch(text, tooltipText); 98 | switcher.active = active ? active : false; 99 | 100 | this.append(button); 101 | this.append(switcherBox); 102 | 103 | switcher.connect('notify::active', (switcher) => { 104 | this.emit('active', switcher.get_active(), switcher); 105 | }); 106 | } 107 | 108 | _initSwitch(text, tooltipText) { 109 | const button = new Gtk.Button({ 110 | label: text, 111 | can_target: false 112 | }); 113 | // Imitate a button 114 | // Here we don't use Gtk.Button with a Gtk.Switch. Because I don't want to get into the trouble that 115 | // the click event can't be propagated down to the Gtk.Switch. 116 | const switcherBox = new Gtk.Box({css_name: 'button'}); 117 | const switcher = new Gtk.Switch({valign: Gtk.Align.CENTER}); 118 | 119 | updateStyle(button, 120 | // Use .text-button if the button displays a label; Use .image-button if it displays an image 121 | `.text-button { 122 | padding-right: 0px; 123 | padding-left: 6px; 124 | border-top-right-radius: 0px; 125 | border-bottom-right-radius: 0px; 126 | }`); 127 | 128 | updateStyle(switcherBox, 129 | `button { 130 | padding-right: 6px; 131 | padding-left: 6px; 132 | border-top-left-radius: 0px; 133 | border-bottom-left-radius: 0px; 134 | }`); 135 | 136 | switcherBox.append(switcher); 137 | 138 | return [button, switcherBox, switcher]; 139 | } 140 | 141 | }); 142 | 143 | export const BoxRemoveButton = GObject.registerClass({ 144 | Signals: {'clicked': {}} 145 | }, class BoxRemoveButton extends Gtk.Box { 146 | 147 | _init() { 148 | super._init(boxProperties); 149 | Object.assign(this, { 150 | hexpand: true, 151 | halign: Gtk.Align.START 152 | }); 153 | 154 | const boxRemoveButton = new Gtk.Button({ 155 | icon_name: 'edit-delete-symbolic', 156 | }); 157 | 158 | this.append(boxRemoveButton); 159 | 160 | boxRemoveButton.connect('clicked', () => { 161 | this.emit('clicked'); 162 | }); 163 | } 164 | 165 | }); 166 | 167 | // TODO This function does not work 168 | export const addScrolledWindow = function(widget) { 169 | const scroll = new Gtk.ScrolledWindow({ 170 | vexpand: true, 171 | hexpand: true, 172 | hscrollbar_policy: Gtk.PolicyType.NEVER, 173 | vscrollbar_policy: Gtk.PolicyType.AUTOMATIC 174 | }); 175 | 176 | const parent = widget.get_parent(); 177 | widget.unparent(); 178 | scroll.set_child(widget); 179 | // How to add widget to Adw.PreferencesPage. parent is a Adw.PreferencesPage? 180 | parent.add_child(scroll); 181 | 182 | } 183 | 184 | -------------------------------------------------------------------------------- /prefsWindowPickableEntry.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import Gio from 'gi://Gio'; 4 | import GObject from 'gi://GObject'; 5 | import Gtk from 'gi://Gtk'; 6 | 7 | import * as PrefsWidgets from './prefsWidgets.js'; 8 | 9 | 10 | export const WindowPickableEntry = GObject.registerClass({ 11 | Signals: { 12 | 'entry-changed': { 13 | param_types: [Gtk.Entry] 14 | }, 15 | 'entry-edit-complete': { 16 | param_types: [Gtk.Entry] 17 | }, 18 | } 19 | }, class WindowPickableEntry extends Gtk.Box { 20 | 21 | _init(entryParams, boxParams) { 22 | 23 | super._init(PrefsWidgets.gtkBoxProperties); 24 | Object.assign(this, boxParams); 25 | 26 | const entry = new Gtk.Entry({ 27 | editable: false, 28 | can_focus: false, 29 | focus_on_click: false, 30 | halign: Gtk.Align.START, 31 | hexpand: true, 32 | // Make sure that text align left 33 | xalign: 0, 34 | width_chars: 20, 35 | max_width_chars: 20, 36 | // ellipsize: Pango.EllipsizeMode.END, 37 | }); 38 | this.entry = entry; 39 | this.pickConditionFunc = entryParams.pickConditionFunc; 40 | Object.assign(entry, entryParams); 41 | 42 | this._initEntry(entry); 43 | entry.set_tooltip_text(entry.get_text()); 44 | 45 | this.append(entry); 46 | this.append(this.chooseButton); 47 | } 48 | 49 | setText(text) { 50 | this.entry.set_text(text); 51 | } 52 | 53 | _initEntry(entry) { 54 | entry.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, 'document-edit-symbolic'); 55 | entry.set_icon_tooltip_text(Gtk.EntryIconPosition.SECONDARY, 'Edit the entry'); 56 | entry.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, true); 57 | const iconPressId = entry.connect('icon-press', (source, icon_pos) => { 58 | if (icon_pos !== Gtk.EntryIconPosition.SECONDARY) 59 | return; 60 | 61 | if (source._showSaveIconAWSM) { 62 | delete source._showSaveIconAWSM; 63 | this._completeEditEntry(entry); 64 | if (this._prefsDialogCloseRequestId) { 65 | const prefsDialogWindow = entry.get_root(); 66 | if (prefsDialogWindow) prefsDialogWindow.disconnect(this._prefsDialogCloseRequestId); 67 | } 68 | } else { 69 | source.block_signal_handler(iconPressId); 70 | 71 | source.set_can_focus(true); 72 | source.set_editable(true); 73 | source.grab_focus_without_selecting(); 74 | // -1 put the cursor to the end 75 | source.set_position(-1); 76 | 77 | source.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, 'emblem-ok-symbolic'); 78 | source.set_icon_tooltip_text(Gtk.EntryIconPosition.SECONDARY, 'Complete editing'); 79 | source._showSaveIconAWSM = true; 80 | 81 | // Save the entry when we close the prefs dialog window 82 | const prefsDialogWindow = entry.get_root(); 83 | if (prefsDialogWindow) { 84 | this._prefsDialogCloseRequestId = prefsDialogWindow.connect('close-request', () => { 85 | this.emit('entry-edit-complete', entry); 86 | prefsDialogWindow.disconnect(this._prefsDialogCloseRequestId); 87 | }); 88 | } 89 | source.unblock_signal_handler(iconPressId); 90 | } 91 | }); 92 | // Accept Enter key to complete the editing 93 | entry.connect('activate', () => { 94 | this._completeEditEntry(entry); 95 | }); 96 | let entryController = Gtk.EventControllerFocus.new(); 97 | entry.add_controller(entryController); 98 | entryController.connect('leave', (source) => { 99 | this._completeEditEntry(entry); 100 | }); 101 | entry.connect('changed', (source) => { 102 | this.emit('entry-changed', source); 103 | }); 104 | 105 | // const image = new Gtk.Image({ 106 | // file: IconFinder.findPath('choose-window-symbolic.svg'), 107 | // }); 108 | const chooseButton = new Gtk.Button({ 109 | icon_name: 'find-location-symbolic', 110 | // label: 'Pick...', 111 | tooltip_text: 'Choose a window to fill the entry based on the current setting', 112 | }); 113 | this.chooseButton = chooseButton; 114 | 115 | PrefsWidgets.updateStyle(entry, 116 | `entry { 117 | border-top-right-radius: 0px; 118 | border-bottom-right-radius: 0px; 119 | }`); 120 | PrefsWidgets.updateStyle(chooseButton, 121 | // Use .text-button if the button displays a label; Use .image-button if it displays an image 122 | `.image-button { 123 | padding-left: 0px; 124 | padding-right: 6px; 125 | border-top-left-radius: 0px; 126 | border-bottom-left-radius: 0px; 127 | }`); 128 | 129 | // Pick a window to fetch application and window infos according to the current rule setting 130 | chooseButton.connect('clicked', (source, pickedWidget) => { 131 | if (this._dbusConnection) { 132 | // Unsubscribe the existing PickWindow DBus service, just in case of modifying another entry. 133 | Gio.DBus.session.signal_unsubscribe(this._dbusConnection); 134 | this._dbusConnection = null; 135 | } 136 | 137 | Gio.DBus.session.call( 138 | 'org.gnome.Shell', 139 | '/org/gnome/shell/extensions/awsm', 140 | 'org.gnome.Shell.Extensions.awsm.PickWindow', 'PickWindow', 141 | null, null, Gio.DBusCallFlags.NO_AUTO_START, -1, null, null); 142 | 143 | this._dbusConnection = this._subscribeSignal('WindowPicked', (conn, sender, obj_path, iface, signal, results) => { 144 | // Unsubscribe the PickWindow DBus service, it's really no necessary to keep the subscription all the time 145 | Gio.DBus.session.signal_unsubscribe(this._dbusConnection); 146 | this._dbusConnection = null; 147 | 148 | this._unfocus(entry); 149 | 150 | const resultsArray = results.recursiveUnpack(); 151 | // Pick nothing, so we ignore this pick 152 | if(!resultsArray.length) { 153 | return; 154 | } 155 | 156 | const [appName, wmClass, wmClassInstance, title] = resultsArray; 157 | let entryValue = ''; 158 | switch (this.pickConditionFunc()) { 159 | case 'wm_class': 160 | entryValue = wmClass; 161 | break; 162 | case 'wm_class_instance': 163 | entryValue = wmClassInstance; 164 | break; 165 | case 'app_name': 166 | entryValue = appName; 167 | break; 168 | case 'title': 169 | entryValue = title; 170 | break; 171 | default: 172 | break; 173 | } 174 | 175 | entry.set_text(entryValue); 176 | entry.set_tooltip_text(entryValue); 177 | this.emit('entry-edit-complete', entry); 178 | }); 179 | }); 180 | 181 | this._subscribeSignal('WindowPickCancelled', () => { 182 | // Unsubscribe the PickWindow DBus service, it's really no necessary to keep the subscription all the time 183 | Gio.DBus.session.signal_unsubscribe(this._dbusConnection); 184 | this._dbusConnection = null; 185 | 186 | this._unfocus(entry); 187 | }); 188 | } 189 | 190 | _subscribeSignal(signalName, callback) { 191 | const dbusConnection = Gio.DBus.session.signal_subscribe( 192 | 'org.gnome.Shell', 'org.gnome.Shell.Extensions.awsm.PickWindow', 193 | signalName, 194 | '/org/gnome/shell/extensions/awsm', null, Gio.DBusSignalFlags.NONE, 195 | callback); 196 | return dbusConnection; 197 | } 198 | 199 | _completeEditEntry(entry) { 200 | entry.set_can_focus(false); 201 | entry.set_editable(false); 202 | this._unfocus(entry); 203 | entry.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, 'document-edit-symbolic'); 204 | entry.set_icon_tooltip_text(Gtk.EntryIconPosition.SECONDARY, 'Edit the entry'); 205 | entry.set_tooltip_text(entry.get_text()); 206 | this.emit('entry-edit-complete', entry); 207 | } 208 | 209 | _unfocus(widget) { 210 | const prefsDialogWindow = widget.get_root(); 211 | if (prefsDialogWindow) 212 | // Pass `null` to unfocus the entry 213 | prefsDialogWindow.set_focus(null); 214 | } 215 | 216 | }); 217 | -------------------------------------------------------------------------------- /restoreSession.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import Shell from 'gi://Shell'; 4 | import Gio from 'gi://Gio'; 5 | import GLib from 'gi://GLib'; 6 | 7 | import * as FileUtils from './utils/fileUtils.js'; 8 | import * as Log from './utils/log.js'; 9 | import {PrefsUtils} from './utils/prefsUtils.js'; 10 | import * as SubprocessUtils from './utils/subprocessUtils.js'; 11 | import * as DateUtils from './utils/dateUtils.js'; 12 | import * as StringUtils from './utils/stringUtils.js'; 13 | 14 | 15 | export const restoreSessionObject = { 16 | // All launching apps by Shell.App#launch() 17 | restoringApps: new Map() 18 | } 19 | 20 | export const RestoreSession = class { 21 | 22 | constructor() { 23 | this._log = new Log.Log(); 24 | this._settings = PrefsUtils.getSettings(); 25 | 26 | this.sessionName = FileUtils.default_sessionName; 27 | this._defaultAppSystem = Shell.AppSystem.get_default(); 28 | this._windowTracker = Shell.WindowTracker.get_default(); 29 | 30 | this._restore_session_interval = this._settings.get_int('restore-session-interval'); 31 | 32 | // TODO Add to Preferences? 33 | // Launch apps using discrete graphics card might cause issues, like the white main window of superproductivity 34 | this._useDiscreteGraphicsCard = false; 35 | 36 | // All launched apps info by Shell.App#launch() 37 | this._restoredApps = new Map(); 38 | 39 | // Tracking cmd and appId mapping 40 | this._cmdAppIdMap = new Map(); 41 | 42 | this._display = global.display; 43 | 44 | this._connectIds = []; 45 | } 46 | 47 | /** 48 | * Restore workspaces and make them persistent, etc 49 | */ 50 | static restoreFromSummary() { 51 | Log.Log.getDefault().debug(`Prepare to restore summary`); 52 | FileUtils.loadSummary().then(([summary, path]) => { 53 | Log.Log.getDefault().info(`Restoring summary from ${path}`); 54 | const savedNWorkspace = summary.n_workspace; 55 | const workspaceManager = global.workspace_manager; 56 | const currentNWorkspace = workspaceManager.n_workspaces; 57 | const moreWorkspace = savedNWorkspace - currentNWorkspace; 58 | if (moreWorkspace) { 59 | for (let i = currentNWorkspace; i <= savedNWorkspace; i++) { 60 | workspaceManager.append_new_workspace(false, DateUtils.get_current_time()); 61 | workspaceManager.get_workspace_by_index(i)._keepAliveId = true; 62 | } 63 | } 64 | }).catch(e => Log.Log.getDefault().error(e)); 65 | } 66 | 67 | restoreSession(sessionName) { 68 | if (!sessionName) { 69 | sessionName = this.sessionName; 70 | } 71 | 72 | const sessions_path = FileUtils.get_sessions_path(); 73 | const session_file_path = GLib.build_filenamev([sessions_path, sessionName]); 74 | if (!GLib.file_test(session_file_path, GLib.FileTest.EXISTS)) { 75 | logError(new Error(`Session file not found: ${session_file_path}`)); 76 | return; 77 | } 78 | 79 | this._log.info(`Restoring saved session from ${session_file_path}`); 80 | try { 81 | this.restoreSessionFromFile(session_file_path); 82 | } catch (e) { 83 | logError(e, `Failed to restore ${session_file_path}`); 84 | } 85 | } 86 | 87 | restoreSessionFromFile(session_file_path) { 88 | const session_file = Gio.File.new_for_path(session_file_path); 89 | let [success, contents] = session_file.load_contents(null); 90 | if (!success) { 91 | return; 92 | } 93 | 94 | let session_config = FileUtils.getJsonObj(contents); 95 | let session_config_objects = session_config.x_session_config_objects; 96 | if (!(session_config_objects && session_config_objects.length)) { 97 | this._log.error(new Error(`Session details not found: ${session_file_path}`)); 98 | global.notify_error(`No session to restore from ${session_file_path}`, `session config is empty.`); 99 | return; 100 | } 101 | 102 | session_config_objects = session_config_objects.filter(session_config_object => { 103 | const desktop_file_id = session_config_object.desktop_file_id; 104 | if (!desktop_file_id) { 105 | return true; 106 | } 107 | const shellApp = this._defaultAppSystem.lookup_app(desktop_file_id); 108 | if (!shellApp) { 109 | return true; 110 | } 111 | 112 | if (this._appIsRunning(shellApp)) { 113 | this._log.debug(`${shellApp.get_name()} is already running`) 114 | return false; 115 | } 116 | 117 | return true; 118 | }); 119 | if (session_config_objects.length === 0) return; 120 | 121 | this._restoreOneSession(session_config_objects.shift()); 122 | if (session_config_objects.length === 0) return; 123 | 124 | this._restoreSessionTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 125 | // In milliseconds. 126 | // Note that this timing might not be precise, see https://gjs-docs.gnome.org/glib20~2.66.1/glib.timeout_add 127 | this._restore_session_interval, 128 | () => { 129 | if (!session_config_objects.length) { 130 | return GLib.SOURCE_REMOVE; 131 | } 132 | this._restoreOneSession(session_config_objects.shift()); 133 | return GLib.SOURCE_CONTINUE; 134 | } 135 | ); 136 | } 137 | 138 | async restorePreviousSession(removeAfterRestore) { 139 | try { 140 | this._log.info(`Restoring the previous session from ${FileUtils.current_session_path}`); 141 | 142 | const ignoringParentFolders = [ 143 | GLib.build_filenamev([FileUtils.current_session_path, 'null']), 144 | ]; 145 | const ignoringFilePaths = [ 146 | GLib.build_filenamev([FileUtils.current_session_path, 'summary.json']) 147 | ]; 148 | FileUtils.listAllSessions(FileUtils.current_session_path, true, (file, info) => { 149 | const contentType = info.get_content_type(); 150 | if (contentType !== 'application/json') { 151 | return; 152 | } 153 | if (ignoringParentFolders.includes(file.get_parent().get_path())) { 154 | return; 155 | } 156 | if (ignoringFilePaths.includes(file.get_path())) { 157 | return; 158 | } 159 | file.load_contents_async( 160 | null, 161 | (file, asyncResult) => { 162 | const [success, contents, _] = file.load_contents_finish(asyncResult); 163 | if (!success) { 164 | return; 165 | } 166 | const sessionConfig = FileUtils.getJsonObj(contents); 167 | sessionConfig._file_path = file.get_path(); 168 | this._restoreOneSession(sessionConfig).then(([launched, running]) => { 169 | if (removeAfterRestore && launched && !running) { 170 | const path = file.get_path(); 171 | this._log.debug(`Restored ${sessionConfig.window_title}(${sessionConfig.app_name}), cleaning ${path}`); 172 | FileUtils.removeFile(path); 173 | } 174 | }).catch(e => this._log.error(e)); 175 | }); 176 | 177 | }); 178 | } catch (error) { 179 | this._log.error(error); 180 | } 181 | } 182 | 183 | async _restoreOneSession(session_config_object) { 184 | const app_name = session_config_object.app_name; 185 | let launched = false; 186 | let running = false; 187 | try { 188 | return await new Promise((resolve, reject) => { 189 | let desktop_file_id = session_config_object.desktop_file_id; 190 | const shell_app = desktop_file_id ? this._defaultAppSystem.lookup_app(desktop_file_id) : null; 191 | if (shell_app) { 192 | const restoringShellAppData = restoreSessionObject.restoringApps.get(shell_app); 193 | if (restoringShellAppData) { 194 | restoringShellAppData.saved_window_sessions.push(session_config_object); 195 | } else { 196 | restoreSessionObject.restoringApps.set(shell_app, { 197 | saved_window_sessions: [session_config_object] 198 | }); 199 | } 200 | 201 | const desktopNumber = session_config_object.desktop_number; 202 | [launched, running] = this.launch(shell_app, desktopNumber); 203 | if (launched) { 204 | if (!running) { 205 | this._log.info(`${app_name} has been launched! Preparing to restore window ${session_config_object.window_title}(${app_name})!`); 206 | } 207 | const existingShellAppData = this._restoredApps.get(shell_app); 208 | if (existingShellAppData) { 209 | existingShellAppData.saved_window_sessions.push(session_config_object); 210 | } else { 211 | this._restoredApps.set(shell_app, { 212 | saved_window_sessions: [session_config_object] 213 | }); 214 | } 215 | } else { 216 | this._log.error(`Failed to launch ${app_name}`, `Failed to launch ${app_name}`); 217 | global.notify_error(`Failed to launch ${app_name}`, `Failed to launch ${app_name}`); 218 | } 219 | resolve([launched, running]); 220 | } else { 221 | // https://gjs-docs.gnome.org/gio20~2.0/gio.subprocesslauncher#method-set_environ 222 | // TODO Support snap apps 223 | 224 | const cmd = session_config_object.cmd; 225 | if (cmd && cmd.length) { 226 | const cmdString = cmd.join(' '); 227 | const pid = this._cmdAppIdMap.get(cmdString); 228 | if (pid) { 229 | this._log.debug(`${app_name} might be running, preparing to restore window (${session_config_object.window_title}) states.`); 230 | 231 | // Here we use pid as the key, because the associated ShellApp might not be instantiated at this moment 232 | const restoringShellAppData = restoreSessionObject.restoringApps.get(pid); 233 | if (restoringShellAppData) { 234 | restoringShellAppData.saved_window_sessions.push(session_config_object); 235 | } else { 236 | restoreSessionObject.restoringApps.set(pid, { 237 | saved_window_sessions: [session_config_object] 238 | }); 239 | } 240 | } 241 | 242 | const launchAppTemplate = FileUtils.desktop_template_launch_app_shell_script; 243 | const launchAppShellScript = StringUtils.format(FileUtils.loadTemplate(launchAppTemplate), {cmdString}); 244 | this._log.info(`Launching ${app_name} via command line ${cmdString}!`); 245 | SubprocessUtils.trySpawnCmdstr(`bash -c '${launchAppShellScript}'`).then( 246 | ([success, status, stdoutInputStream, stderrInputStream]) => { 247 | if (success) { 248 | stdoutInputStream.read_line_async( 249 | GLib.PRIORITY_DEFAULT, 250 | null, 251 | (stream, res) => { 252 | try { 253 | let pid = stream.read_line_finish_utf8(res)[0]; 254 | if (!pid) return; 255 | 256 | pid = Number(pid); 257 | this._cmdAppIdMap.set(cmdString, pid); 258 | const restoringShellAppData = restoreSessionObject.restoringApps.get(pid); 259 | if (restoringShellAppData) { 260 | restoringShellAppData.saved_window_sessions.push(session_config_object); 261 | } else { 262 | restoreSessionObject.restoringApps.set(pid, { 263 | saved_window_sessions: [session_config_object] 264 | }); 265 | } 266 | launched = true; 267 | resolve([launched, running]); 268 | } catch (e) { 269 | this._log.error(e); 270 | reject(e); 271 | } 272 | } 273 | ); 274 | } else { 275 | if (status === 79) { 276 | launched = true; 277 | running = true; 278 | this._log.info(`${app_name} is running, skipping`) 279 | } else { 280 | const msg = `Failed to launch ${app_name} via command line`; 281 | let errorDetail = `Can't restore this app from ${session_config_object._file_path}: ${stderr}.`; 282 | this._log.error(`${msg}. output: ${errorDetail}`); 283 | global.notify_error(`${msg}`, errorDetail); 284 | } 285 | resolve([launched, running]); 286 | } 287 | }).catch(e => { 288 | this._log.error(e) 289 | reject(e); 290 | }); 291 | } else { 292 | // TODO try to launch via app_info by searching the app name? 293 | let errorMsg = `Failed to launch ${app_name} via command line`; 294 | let errorDetail = `Can't restore this app from ${session_config_object._file_path}: Invalid command line: ${cmd}.`; 295 | this._log.error(errorMsg, errorDetail); 296 | global.notify_error(errorMsg, errorDetail); 297 | resolve([launched, running]); 298 | } 299 | } 300 | }); 301 | } catch (e) { 302 | logError(e, `Failed to restore ${app_name}`); 303 | if (!launched) { 304 | global.notify_error(`Failed to restore ${app_name}`, e.message); 305 | } 306 | return [launched, running]; 307 | } 308 | } 309 | 310 | launch(shellApp, desktopNumber) { 311 | if (this._restoredApps.has(shellApp)) { 312 | this._log.info(`${shellApp.get_name()} is restored, skipping`); 313 | return [true, false]; 314 | } 315 | 316 | if (this._appIsRunning(shellApp)) { 317 | this._log.info(`${shellApp.get_name()} is running, skipping`); 318 | // Delete shellApp from restoringApps to prevent it move the same app when close and open it manually. 319 | restoreSessionObject.restoringApps.delete(shellApp); 320 | return [true, true]; 321 | } 322 | 323 | const launched = shellApp.launch( 324 | // 0 for current event timestamp 325 | 0, 326 | desktopNumber, 327 | this._getProperGpuPref(shellApp)); 328 | return [launched, false]; 329 | } 330 | 331 | _appIsRunning(app) { 332 | // Running apps can be empty even if there are apps running when gnome-shell starting 333 | const running_apps = this._defaultAppSystem.get_running(); 334 | for (const running_app of running_apps) { 335 | if (running_app.get_id() === app.get_id() && 336 | running_app.get_state() >= Shell.AppState.STARTING) { 337 | return true; 338 | } 339 | } 340 | return false; 341 | } 342 | 343 | _getProperGpuPref(shell_app) { 344 | if (this._useDiscreteGraphicsCard) { 345 | const app_info = shell_app.get_app_info(); 346 | if (app_info) { 347 | return app_info.get_boolean('PrefersNonDefaultGPU') 348 | ? Shell.AppLaunchGpu.DEFAULT 349 | : Shell.AppLaunchGpu.DISCRETE; 350 | } 351 | } 352 | return Shell.AppLaunchGpu.DEFAULT; 353 | } 354 | 355 | destroy() { 356 | if (restoreSessionObject.restoringApps) { 357 | restoreSessionObject.restoringApps.clear(); 358 | restoreSessionObject.restoringApps = null; 359 | } 360 | 361 | if (this._restoredApps) { 362 | this._restoredApps.clear(); 363 | this._restoredApps = null; 364 | } 365 | 366 | if (this._defaultAppSystem) { 367 | this._defaultAppSystem = null; 368 | } 369 | 370 | if (this._windowTracker) { 371 | this._windowTracker = null; 372 | } 373 | 374 | if (this._log) { 375 | this._log.destroy(); 376 | this._log = null; 377 | } 378 | 379 | if (this._connectIds) { 380 | for (let [obj, id] of this._connectIds) { 381 | obj.disconnect(id); 382 | } 383 | this._connectIds = null; 384 | } 385 | 386 | if (this._restoreSessionTimeoutId) { 387 | GLib.Source.remove(this._restoreSessionTimeoutId); 388 | this._restoreSessionTimeoutId = null; 389 | } 390 | 391 | } 392 | 393 | } 394 | -------------------------------------------------------------------------------- /schemas/gschemas.compiled: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nlpsuge/gnome-shell-extension-another-window-session-manager/9379f86e95fd121bc76d10d69936b622f4ee81d1/schemas/gschemas.compiled -------------------------------------------------------------------------------- /schemas/org.gnome.shell.extensions.another-window-session-manager.gschema.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | true 8 | Enable or disable the ability of closing windows by customed rules, such as a shortcut 9 | 10 | 11 | 12 | 13 | 14 | '[]' 15 | The mapping of xid and stable sequence of a window on X11 16 | 17 | 18 | 19 | 20 | '{}' 21 | Rules that are used to close applications 22 | 23 | Rules that are used to close applications 24 | 25 | 26 | 27 | '{}' 28 | Rules that are used to close applications by keyword 29 | 30 | Rules that are used to close applications by keyword 31 | 32 | 33 | 34 | '{}' 35 | 36 | A whitelist that contains apps or windwows that can be closed safely, 37 | even they have multiple windows. 38 | 39 | 40 | 41 | 42 | 43 | '' 44 | Session(s) restored on startup 45 | 46 | 47 | 48 | 49 | true 50 | 51 | Enable to close running apps and windows while Logout, Reboot and Shutdown 52 | via endSessionDialog. 53 | 54 | 55 | 56 | 57 | 58 | true 59 | 60 | Show a notification when users click buttons on the popup menu to save a session. 61 | 62 | 63 | 64 | 65 | 66 | false 67 | Enable to Restore previous apps and windows at startup 68 | 69 | 70 | 71 | 72 | false 73 | Enable to restore session(s) on startup 74 | 75 | Enable to restore session(s) on startup and install 76 | `~/.config/autostart/_gnome-shell-extension-another-window-session-manager.desktop`. 77 | 78 | 79 | 80 | 0 81 | The interval restoring applications 82 | 83 | Restore applications at intervals of some milliseconds, up to 5 minutes 84 | 85 | 86 | 87 | false 88 | Restore at startup without asking 89 | 90 | Restore immediately at startup without asking 91 | 92 | 93 | 94 | 10 95 | Set the timer on the AutostartDialog for some seconds 96 | 97 | Set the timer on the AutostartDialog for some seconds, the upper value is 3600s 98 | 99 | 100 | 101 | 5 102 | Start to restore the previous session after a specified delay 103 | 104 | Start to restore the previous session after a specified delay, the upper value is 3600s 105 | 106 | 107 | 108 | 5 109 | Autostart delay 110 | 111 | A specified amount of time to delay to execute the target command. 112 | 113 | 114 | 115 | true 116 | Restore window edge tiling 117 | 118 | 119 | 120 | 121 | true 122 | Raise all two windows together 123 | 124 | Raise all two windows together while one of the pairs is raised while tiling. 125 | 126 | 127 | 128 | true 129 | Stash and restore states 130 | 131 | Stash states while Gnome Shell restarts via `Alt+F2 -> r` or `killall -3 gnome-shell`, 132 | and restore the windows states after the restart finishes. 133 | 134 | Only enabled on X11, since Wayand doesn't support restart on fly. 135 | 136 | 137 | 138 | true 139 | Show or hide indicator on the panel 140 | 141 | 142 | 143 | 144 | 145 | false 146 | Enable or disable the debugging mode 147 | 148 | Enable the debugging mode to see what happened, for debugging or issue reporting or development purpose 149 | 150 | 151 | 152 | false 153 | Enable or disable verbose logging 154 | 155 | Enable verbose logging for more informational output like windows position saving, session information etc 156 | 157 | 158 | 159 | -------------------------------------------------------------------------------- /stylesheet.css: -------------------------------------------------------------------------------- 1 | .awsm-toggle-switch { 2 | background-image: url("./icons/toggle-off-autorestore-symbolic.svg"); 3 | } 4 | 5 | .awsm-toggle-switch:checked { 6 | background-image: url("./icons/toggle-on-autorestore-symbolic.svg"); 7 | } 8 | 9 | /* Restore Session Dialog */ 10 | .restore-session-dialog { 11 | width: 30em; 12 | } 13 | 14 | 15 | /** 16 | * *-tooltip: Adapted from: https://github.com/GSConnect/gnome-shell-extension-gsconnect/blob/master/src/stylesheet.css 17 | */ 18 | .awsm-tooltip { 19 | border-radius: 3px; 20 | min-width: 0; 21 | min-height: 0; 22 | padding: 6px; 23 | } 24 | 25 | .awsm-tooltip > StBoxLayout { 26 | spacing: 6px; 27 | } 28 | 29 | .awsm-tooltip StIcon { 30 | icon-size: 16px; 31 | } 32 | 33 | .awsm-tooltip StLabel { 34 | font-weight: normal; 35 | text-align: left; 36 | } 37 | 38 | .awsm-tooltip StLabel:rtl { 39 | text-align: right; 40 | } 41 | 42 | 43 | 44 | .session-menu-section { 45 | max-height:600px; 46 | } 47 | 48 | .confirm-before-operate { 49 | color: #F00; 50 | font-weight: bold; 51 | font-style: italic 52 | } 53 | 54 | .confirm-before-operate:hover, .confirm-before-operate:focus { 55 | background-color: rgba(255,255,255,0.2); 56 | border: none; 57 | padding: 5px; 58 | } 59 | 60 | .button-item > StIcon { 61 | icon-size: 14px; 62 | } 63 | 64 | .aws-item-separator { 65 | border-radius: 32px; 66 | padding: 4px; 67 | /* 68 | Remove cycle / border in the background in case everyone think this separator is a clickable button, actually it's just a view-only separator. 69 | 70 | https://developer.mozilla.org/en-US/docs/Web/CSS/border 71 | */ 72 | border: none; 73 | } 74 | 75 | .aws-item-separator > StIcon { 76 | icon-size: 14px; 77 | } 78 | 79 | /** 80 | Add highlight effect when hover over the buttons in the item 81 | 82 | Based on https://gitlab.com/bartl/todo-txt-gnome-shell-extension/-/blob/master/stylesheet.css 83 | */ 84 | .aws-item-button { 85 | border-radius: 32px; 86 | padding: 4px; 87 | border: 1px solid #282c2c; 88 | } 89 | 90 | .aws-item-button:hover, .aws-item-button:focus { 91 | background-color: rgba(255,255,255,0.2); 92 | border: none; 93 | padding: 5px; 94 | } 95 | 96 | .aws-item-button > StIcon { 97 | icon-size: 14px; 98 | } 99 | -------------------------------------------------------------------------------- /template/60-awsm-ydotool-uinput.rules: -------------------------------------------------------------------------------- 1 | # This file is part of gnome-shell-extension-another-window-session-manager 2 | 3 | # See: 4 | # https://github.com/ValveSoftware/steam-devices/blob/master/60-steam-input.rules 5 | # https://github.com/ReimuNotMoe/ydotool/issues/25 6 | 7 | # ydotool udev write access 8 | KERNEL=="uinput", SUBSYSTEM=="misc", TAG+="uaccess", OPTIONS+="static_node=uinput" 9 | -------------------------------------------------------------------------------- /template/_awsm-restore-previous-session.desktop: -------------------------------------------------------------------------------- 1 | # This file is part of gnome-shell-extension-another-window-session-manager 2 | 3 | # Do NOT modify this file, which could be overridden or deleted by this extension. 4 | 5 | 6 | [Desktop Entry] 7 | Name=Restore the previous session at startup 8 | Comment=Restore the previous session at startup 9 | Icon= 10 | Exec=bash -c 'sleep ${autostartDelay} && gdbus call --session --dest org.gnome.Shell.Extensions.awsm --object-path /org/gnome/Shell/Extensions/awsm --method org.gnome.Shell.Extensions.awsm.Autostart.RestorePreviousSession "{}"' 11 | Terminal=false 12 | Type=Application 13 | # This option does not work on my machine, so I use `sleep` instead ... 14 | X-GNOME-Autostart-Delay= 15 | -------------------------------------------------------------------------------- /template/_gnome-shell-extension-another-window-session-manager.desktop: -------------------------------------------------------------------------------- 1 | # This file is part of gnome-shell-extension-another-window-session-manager 2 | 3 | # Do NOT modify this file, which could be overridden or deleted by this extension. 4 | 5 | 6 | [Desktop Entry] 7 | Name=Restore saved session(s) at startup 8 | Comment=Restore saved session(s) at startup 9 | Icon= 10 | Exec=bash -c 'sleep ${autostartDelay} && gdbus call --session --dest org.gnome.Shell.Extensions.awsm --object-path /org/gnome/Shell/Extensions/awsm --method org.gnome.Shell.Extensions.awsm.Autostart.RestoreSession' 11 | Terminal=false 12 | Type=Application 13 | # This option does not work on my machine, so I use `sleep` instead ... 14 | X-GNOME-Autostart-Delay= 15 | -------------------------------------------------------------------------------- /template/launch-app.sh: -------------------------------------------------------------------------------- 1 | if ! pgrep -f "${cmdString}" | grep -v "$$"; then 2 | ${cmdString} > /dev/null & 3 | echo $! >&1 4 | else 5 | # App must be started or running 6 | # See: https://tldp.org/LDP/abs/html/exitcodes.html#EXITCODESREF 7 | exit 79 8 | fi 9 | 10 | -------------------------------------------------------------------------------- /template/template.desktop: -------------------------------------------------------------------------------- 1 | # This file is part of gnome-shell-extension-another-window-session-manager 2 | 3 | # I adopted this file from VirtualBox 4 | # Generated by gnome-shell-extension-another-window-session-manager 5 | # https://github.com/nlpsuge/gnome-shell-extension-another-window-session-manager 6 | 7 | 8 | [Desktop Entry] 9 | Encoding=UTF-8 10 | Version=1.0 11 | Name=${appName} 12 | Comment=${appName} 13 | Type=Application 14 | Exec=${commandLine} 15 | Icon=${icon} 16 | StartupWMClass=${wmClass} 17 | # If the former does not work, use the below line instead. 18 | # If it still does not work, please fill an issue at: 19 | # https://github.com/nlpsuge/gnome-shell-extension-another-window-session-manager/issues 20 | # StartupWMClass=${wmClassInstance} 21 | -------------------------------------------------------------------------------- /ui/autostart.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* exported AutostartServiceProvider, AutostartService, AutostartDialog */ 4 | 5 | import GObject from 'gi://GObject'; 6 | import Gio from 'gi://Gio'; 7 | import GLib from 'gi://GLib'; 8 | import Clutter from 'gi://Clutter'; 9 | 10 | import * as EndSessionDialog from 'resource:///org/gnome/shell/ui/endSessionDialog.js'; 11 | 12 | import {gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js'; 13 | 14 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 15 | import * as Dialog from 'resource:///org/gnome/shell/ui/dialog.js'; 16 | import * as ModalDialog from 'resource:///org/gnome/shell/ui/modalDialog.js'; 17 | 18 | import * as Autoclose from './autoclose.js'; 19 | import * as RestoreSession from '../restoreSession.js'; 20 | import * as Constants from '../constants.js'; 21 | 22 | import * as Log from '../utils/log.js'; 23 | import {PrefsUtils} from '../utils/prefsUtils.js'; 24 | import * as FileUtils from '../utils/fileUtils.js'; 25 | 26 | 27 | let _requiredToRestorePrevious = false; 28 | 29 | export const AutostartServiceProvider = GObject.registerClass( 30 | class AutostartServiceProvider extends GObject.Object { 31 | 32 | _init() { 33 | super._init(); 34 | 35 | this._log = new Log.Log(); 36 | 37 | this._autostartDbusXml = new TextDecoder().decode( 38 | FileUtils.current_extension_dir.get_child('dbus-interfaces').get_child('org.gnome.Shell.Extensions.awsm.Autostart.xml').load_contents(null)[1]); 39 | 40 | this._autostartService = null; 41 | this._autostartDbusImpl = null; 42 | 43 | // https://gjs.guide/guides/gio/dbus.html#exporting-interfaces 44 | this._dbusNameOwnerId = Gio.bus_own_name( 45 | Gio.BusType.SESSION, 46 | 'org.gnome.Shell.Extensions.awsm', 47 | Gio.BusNameOwnerFlags.NONE, 48 | this.onBusAcquired.bind(this), 49 | this.onNameAcquired.bind(this), 50 | this.onNameLost.bind(this), 51 | ); 52 | 53 | 54 | } 55 | 56 | onBusAcquired(connection, name) { 57 | this._log.debug(`DBus bus with name ${name} acquired!`); 58 | 59 | this._autostartService = new AutostartService(); 60 | 61 | // Gio.DBusExportedObject.wrapJSObject(interfaceInfo, jsObj) is a private method of gjs 62 | // See: https://gitlab.gnome.org/GNOME/gjs/-/blob/master/modules/core/overrides/Gio.js#L391 63 | this._autostartDbusImpl = Gio.DBusExportedObject.wrapJSObject(this._autostartDbusXml, this._autostartService); 64 | this._autostartDbusImpl.export(connection, '/org/gnome/Shell/Extensions/awsm'); 65 | 66 | } 67 | 68 | onNameAcquired(connection, name) { 69 | this._log.debug(`DBus name ${name} acquired!`); 70 | } 71 | 72 | onNameLost(connection, name) { 73 | this._log.debug(`Dbus name ${name} lost`); 74 | } 75 | 76 | disable() { 77 | // To avoid the below error 78 | // JS ERROR: Gio.IOErrorEnum: An object is already exported for the interface org.gnome.Shell.Extensions.awsm.Autostart at /org/gnome/Shell/Extensions/awsm 79 | // when disable and enable this extension 80 | this._autostartDbusImpl.flush(); 81 | this._autostartDbusImpl.unexport(); 82 | 83 | if (this._autostartService) { 84 | this._autostartService._disable(); 85 | this._autostartService = null; 86 | } 87 | } 88 | }); 89 | 90 | const AutostartService = GObject.registerClass( 91 | class AutostartService extends GObject.Object { 92 | 93 | _init() { 94 | super._init(); 95 | 96 | this._log = new Log.Log(); 97 | this._autostartDialog = null; 98 | this._restorePreviousSourceId = 0; 99 | this._idleIdOpenRestoreSessionDialog = 0; 100 | 101 | this._settings = PrefsUtils.getSettings(); 102 | this._sessionName = this._settings.get_string(Constants.PREFS_SETTING_AUTORESTORE_SESSIONS); 103 | } 104 | 105 | // Call this method asynchronously through `gdbus call --session --dest org.gnome.Shell.Extensions.awsm --object-path /org/gnome/Shell/Extensions/awsm --method org.gnome.Shell.Extensions.awsm.Autostart.RestoreSession` 106 | RestoreSession() { 107 | const enableRestoreSelectedSession = this._settings.get_boolean('enable-autorestore-sessions'); 108 | if (!enableRestoreSelectedSession) { 109 | const enableRestorePreviousSession = this._settings.get_boolean('enable-restore-previous-session'); 110 | if (enableRestorePreviousSession) { 111 | return "Ignoring this operation. RestoreSession is disabled, but RestorePreviousSession is enabled"; 112 | } 113 | const disabledFeatureMsg = "ERROR: RestoreSession is disabled, please enable it through 'Preferences -> Restore sessions -> Restore selected session at startup'"; 114 | Main.notify('Another Window Session Manager', disabledFeatureMsg); 115 | return disabledFeatureMsg; 116 | } 117 | 118 | const restoringMsg = `Restoring selected session '${this._sessionName}'`; 119 | this._log.info(restoringMsg); 120 | Main.notify('Another Window Session Manager', restoringMsg); 121 | 122 | this._autostartDialog = new AutostartDialog(); 123 | if (this._settings.get_boolean('restore-at-startup-without-asking')) { 124 | this._autostartDialog._confirm(); 125 | return `Restore session '${this._sessionName}' without asking ...`; 126 | } else { 127 | // Since this._autostartDialog.open() is idempotent (it will check the dialog state), 128 | // it's ok to call it twice. 129 | // Before the startup-complete emits, `Main.pushModal(this, params).get_seat_state()` 130 | // returns CLUTTER_GRAB_STATE_NONE (0), which causes the dialog can't open. See: modalDialog.open() -> modalDialog.pushModal() 131 | Main.layoutManager.connect('startup-complete', () => { 132 | this._idleIdOpenRestoreSessionDialog = GLib.idle_add(GLib.PRIORITY_LOW, () => { 133 | this._autostartDialog.open(); 134 | this._idleIdOpenRestoreSessionDialog = null; 135 | return GLib.SOURCE_REMOVE; 136 | }); 137 | }); 138 | this._autostartDialog.open(); 139 | return 'Opening dialog to restore ...'; 140 | } 141 | 142 | } 143 | 144 | // TODO Press some hotkey (like Ctrl) so this time will not restore the previous session? 145 | // Call this method asynchronously through, for example: 146 | // `gdbus call --session --dest org.gnome.Shell.Extensions.awsm --object-path /org/gnome/Shell/Extensions/awsm --method org.gnome.Shell.Extensions.awsm.Autostart.RestorePreviousSession "{'removeAfterRestore': }"` 147 | // `gdbus call --session --dest org.gnome.Shell.Extensions.awsm --object-path /org/gnome/Shell/Extensions/awsm --method org.gnome.Shell.Extensions.awsm.Autostart.RestorePreviousSession "{}"` 148 | RestorePreviousSession(args) { 149 | let removeAfterRestore = args.removeAfterRestore; 150 | if (removeAfterRestore) { 151 | removeAfterRestore = removeAfterRestore.get_boolean(); 152 | } else { 153 | removeAfterRestore = true; 154 | } 155 | return this._restorePreviousSession(removeAfterRestore); 156 | } 157 | 158 | _restorePreviousSession(removeAfterRestore) { 159 | const enableRestorePreviousSession = this._settings.get_boolean('enable-restore-previous-session'); 160 | if (!enableRestorePreviousSession) { 161 | const enableRestoreSelectedSession = this._settings.get_boolean('enable-autorestore-sessions'); 162 | if (enableRestoreSelectedSession) { 163 | return "Ignoring this operation. RestorePreviousSession is disabled, but RestoreSession is enabled"; 164 | } 165 | const disabledFeatureMsg = "ERROR: RestorePreviousSession is disabled, please enable it through 'Preferences -> Restore sessions -> Restore previous apps and windows at startup'"; 166 | Main.notify('Another Window Session Manager', disabledFeatureMsg); 167 | return disabledFeatureMsg; 168 | } 169 | 170 | if (!Main.layoutManager._startingUp) { 171 | const msg = 'Restoring the previous apps and windows'; 172 | this._log.info(`${msg}, gnome shell layoutManager has been started up.`); 173 | Main.notify('Another Window Session Manager', msg); 174 | 175 | this._restorePreviousWithDelay(removeAfterRestore); 176 | return msg; 177 | } else { 178 | if (_requiredToRestorePrevious) return; 179 | 180 | _requiredToRestorePrevious = true; 181 | const msg = 'Required to restore the previous apps and windows'; 182 | Main.notify('Another Window Session Manager', msg); 183 | Main.layoutManager.connect('startup-complete', () => { 184 | const msg = 'Restoring the previous apps and windows'; 185 | this._log.info(`${msg} after startup-complete`); 186 | Main.notify('Another Window Session Manager', msg); 187 | this._restorePreviousWithDelay(removeAfterRestore); 188 | }); 189 | return msg; 190 | } 191 | 192 | } 193 | 194 | _restorePreviousWithDelay(removeAfterRestore) { 195 | const restorePreviousDelay = this._settings.get_int('restore-previous-delay'); 196 | this._restorePreviousSourceId = GLib.timeout_add(GLib.PRIORITY_LOW, restorePreviousDelay, 197 | () => { 198 | const restoreSession = new RestoreSession.RestoreSession(); 199 | restoreSession.restorePreviousSession(removeAfterRestore); 200 | return GLib.SOURCE_REMOVE; 201 | }); 202 | } 203 | 204 | _disable() { 205 | if (this._autostartDialog) { 206 | this._autostartDialog.destroy(); 207 | this._autostartDialog = null; 208 | } 209 | if (this._restorePreviousSourceId) { 210 | GLib.Source.remove(this._restorePreviousSourceId); 211 | this._restorePreviousSourceId = null; 212 | } 213 | if (this._idleIdOpenRestoreSessionDialog) { 214 | GLib.Source.remove(this._idleIdOpenRestoreSessionDialog); 215 | this._idleIdOpenRestoreSessionDialog = null; 216 | } 217 | } 218 | 219 | }); 220 | 221 | // Based on endSessionDialog in Gnome shell 222 | const AutostartDialog = GObject.registerClass( 223 | class AutostartDialog extends ModalDialog.ModalDialog { 224 | 225 | _init() { 226 | super._init({ 227 | styleClass: 'restore-session-dialog', 228 | destroyOnClose: true 229 | }); 230 | 231 | this._settings = PrefsUtils.getSettings(); 232 | 233 | this._sessionName = this._settings.get_string(Constants.PREFS_SETTING_AUTORESTORE_SESSIONS); 234 | 235 | this._totalSecondsToStayOpen = this._settings.get_int('autorestore-sessions-timer'); 236 | this._secondsLeft = 0; 237 | 238 | this.connect('opened', this._onOpened.bind(this)); 239 | 240 | this._confirmDialogContent = new Dialog.MessageDialogContent(); 241 | this._confirmDialogContent.title = `Restore session '${this._sessionName}'`; 242 | 243 | this.addButton({ 244 | action: this._cancel.bind(this), 245 | label: _('Cancel'), 246 | key: Clutter.KEY_Escape, 247 | }); 248 | 249 | this._confirmButton = this.addButton({ 250 | action: () => { 251 | this.close(); 252 | let signalId = this.connect('closed', () => { 253 | this.disconnect(signalId); 254 | this._confirm(); 255 | }); 256 | }, 257 | label: _('Confirm'), 258 | }); 259 | 260 | this.contentLayout.add_child(this._confirmDialogContent); 261 | 262 | } 263 | 264 | _confirm() { 265 | Autoclose.autocloseObject.sessionClosedByUser = false; 266 | const _restoreSession = new RestoreSession.RestoreSession(); 267 | _restoreSession.restoreSession(this._sessionName); 268 | } 269 | 270 | _cancel() { 271 | this.close(); 272 | } 273 | 274 | _onOpened() { 275 | let open = this.state == ModalDialog.State.OPENING || this.state == ModalDialog.State.OPENED; 276 | if (!open) 277 | return; 278 | 279 | if (this._sessionName) { 280 | const [exists, sessionFilePath] = FileUtils.sessionExists(this._sessionName); 281 | if (exists) { 282 | this._startTimer(); 283 | this._sync(); 284 | } else { 285 | this._confirmDialogContent.description = `ERROR: Session '${this._sessionName}' does not exist`; 286 | this._confirmDialogContent._description.set_style('color:red;'); 287 | this._confirmButton.set_reactive(false); 288 | } 289 | } else { 290 | this._confirmDialogContent.description = "ERROR: You don't select any session to restore"; 291 | this._confirmDialogContent._description.set_style('color:red;'); 292 | this._confirmButton.set_reactive(false); 293 | } 294 | } 295 | 296 | _sync() { 297 | 298 | const displayTime = EndSessionDialog._roundSecondsToInterval(this._totalSecondsToStayOpen, 299 | this._secondsLeft, 300 | 1); 301 | const desc = _.ngettext('\'' + this._sessionName + '\' will be restored in %d second', 302 | '\'' + this._sessionName + '\' will be restored in %d seconds', displayTime).format(displayTime); 303 | this._confirmDialogContent.description = desc; 304 | 305 | } 306 | 307 | _startTimer() { 308 | let startTime = GLib.get_monotonic_time(); 309 | this._secondsLeft = this._totalSecondsToStayOpen; 310 | 311 | this._timerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 1, () => { 312 | let currentTime = GLib.get_monotonic_time(); 313 | let secondsElapsed = (currentTime - startTime) / 1000000; 314 | 315 | this._secondsLeft = this._totalSecondsToStayOpen - secondsElapsed; 316 | if (this._secondsLeft > 0) { 317 | this._sync(); 318 | return GLib.SOURCE_CONTINUE; 319 | } 320 | 321 | this._confirm(); 322 | this.close(); 323 | this._timerId = 0; 324 | 325 | return GLib.SOURCE_REMOVE; 326 | }); 327 | GLib.Source.set_name_by_id(this._timerId, '[gnome-shell-extension-another-window-session-manager] this._confirm'); 328 | } 329 | 330 | destroy() { 331 | if (this._timerId > 0) { 332 | GLib.source_remove(this._timerId); 333 | this._timerId = 0; 334 | } 335 | this._secondsLeft = 0; 336 | } 337 | 338 | 339 | }); -------------------------------------------------------------------------------- /ui/button.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import GObject from 'gi://GObject'; 4 | import St from 'gi://St'; 5 | import Clutter from 'gi://Clutter'; 6 | 7 | import * as IconFinder from '../utils/iconFinder.js'; 8 | 9 | 10 | export const Button = GObject.registerClass( 11 | class Button extends GObject.Object { 12 | 13 | _init(properties) { 14 | super._init(); 15 | 16 | this.button_style_class = null; 17 | this.icon_symbolic = null; 18 | 19 | Object.assign(this, properties); 20 | 21 | this.button = this._createButton(this.icon_symbolic); 22 | 23 | } 24 | 25 | _createButton(iconSymbolic) { 26 | let icon = new St.Icon({ 27 | gicon: IconFinder.find(iconSymbolic), 28 | style_class: 'system-status-icon' 29 | }); 30 | 31 | let button = new St.Button({ 32 | style_class: this.button_style_class ? this.button_style_class : 'aws-item-button', 33 | can_focus: true, 34 | child: icon, 35 | x_align: Clutter.ActorAlign.END, 36 | x_expand: false, 37 | y_expand: true, 38 | track_hover: true 39 | }); 40 | 41 | return button; 42 | } 43 | 44 | destroy() { 45 | if (this.button) { 46 | this.button = null; 47 | } 48 | 49 | } 50 | 51 | }); 52 | -------------------------------------------------------------------------------- /ui/popupMenuButtonItems.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import GObject from 'gi://GObject'; 4 | import St from 'gi://St'; 5 | import Clutter from 'gi://Clutter'; 6 | 7 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 8 | import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; 9 | 10 | import * as SaveSession from '../saveSession.js'; 11 | import * as CloseSession from '../closeSession.js'; 12 | import * as RestoreSession from '../restoreSession.js'; 13 | 14 | import * as FileUtils from '../utils/fileUtils.js'; 15 | import * as Log from '../utils/log.js'; 16 | 17 | import {Button} from './button.js'; 18 | 19 | 20 | export const PopupMenuButtonItems = GObject.registerClass( 21 | class PopupMenuButtonItems extends GObject.Object { 22 | 23 | _init() { 24 | super._init(); 25 | this.buttonItems = []; 26 | this.addButtonItems(); 27 | } 28 | 29 | addButtonItems() { 30 | const popupMenuButtonItemClose = new PopupMenuButtonItemClose('close-symbolic.svg'); 31 | const popupMenuButtonItemSave = new PopupMenuButtonItemSave('save-symbolic.svg'); 32 | 33 | this.buttonItems.push(popupMenuButtonItemClose); 34 | this.buttonItems.push(popupMenuButtonItemSave); 35 | } 36 | 37 | }); 38 | 39 | 40 | const PopupMenuButtonItem = GObject.registerClass( 41 | class PopupMenuButtonItem extends PopupMenu.PopupMenuItem { 42 | 43 | _init() { 44 | super._init(''); 45 | 46 | this.yesButton = null; 47 | this.noButton = null; 48 | } 49 | 50 | /** 51 | * Hide both Yes and No buttons by default 52 | */ 53 | createYesAndNoButtons() { 54 | this.yesButton = this.createButton('emblem-ok-symbolic'); 55 | this.noButton = this.createButton('edit-undo-symbolic'); 56 | this.yesButton.add_style_class_name('confirm-before-operate'); 57 | this.noButton.add_style_class_name('confirm-before-operate'); 58 | this.hideYesAndNoButtons(); 59 | } 60 | 61 | showYesAndNoButtons() { 62 | this.yesButton.show(); 63 | this.noButton.show(); 64 | } 65 | 66 | hideYesAndNoButtons() { 67 | this.yesButton.hide(); 68 | this.noButton.hide(); 69 | } 70 | 71 | createButton(iconSymbolic) { 72 | const button = new Button({ 73 | icon_symbolic: iconSymbolic, 74 | button_style_class: 'button-item', 75 | }).button; 76 | return button; 77 | } 78 | 79 | createTimeLine() { 80 | // Set actor when using 81 | const timeline = new Clutter.Timeline({ 82 | // 2s 83 | duration: 2000, 84 | repeat_count: 0, 85 | }); 86 | return timeline; 87 | } 88 | 89 | // Add the icon description 90 | addIconDescription(iconDescription) { 91 | this.iconDescriptionLabel = new St.Label({ 92 | text: iconDescription 93 | }); 94 | this.actor.add_child(this.iconDescriptionLabel); 95 | } 96 | 97 | }); 98 | 99 | 100 | const PopupMenuButtonItemClose = GObject.registerClass( 101 | class PopupMenuButtonItemClose extends PopupMenuButtonItem { 102 | 103 | _init(iconSymbolic) { 104 | super._init(); 105 | this.confirmLabel; 106 | 107 | this.closingLabel; 108 | 109 | this.closeSession = new CloseSession.CloseSession(CloseSession.flags.closeWindows); 110 | 111 | this._createButton(iconSymbolic); 112 | this.addIconDescription('Close open windows'); 113 | this._addConfirm(); 114 | this._addYesAndNoButtons(); 115 | this._addClosingPrompt(); 116 | 117 | this._hideConfirm(); 118 | 119 | this._timeline = this.createTimeLine(); 120 | 121 | // Respond to menu item's 'activate' signal so user don't need to click the icon whose size is too small to find to click 122 | this.connect('activate', this._onActivate.bind(this)); 123 | 124 | } 125 | 126 | _onActivate() { 127 | this._onClicked(); 128 | } 129 | 130 | _hideConfirm() { 131 | this.confirmLabel.hide(); 132 | this.hideYesAndNoButtons(); 133 | this.closingLabel.hide(); 134 | } 135 | 136 | _addYesAndNoButtons() { 137 | super.createYesAndNoButtons(); 138 | 139 | this.yesButton.connect('clicked', () => { 140 | // TODO Do this when enable_close_by_rules is true? 141 | this._parent.close(); 142 | if (Main.overview.visible) { 143 | Main.overview.toggle(); 144 | } 145 | 146 | RestoreSession.restoreSessionObject.restoringApps.clear(); 147 | this.closeSession.closeWindows(); 148 | this._hideConfirm(); 149 | 150 | // Set the actor the timeline is associated with to make sure Clutter.Timeline works normally. 151 | // Set the actor in new Clutter.Timeline don't work 152 | this._timeline.set_actor(this.closingLabel); 153 | this._timeline.connect('new-frame', (_timeline, _frame) => { 154 | this.closingLabel.show(); 155 | }); 156 | this._timeline.start(); 157 | this._timeline.connect('completed', () => { 158 | this._timeline.stop(); 159 | this.closingLabel.hide(); 160 | }); 161 | 162 | }); 163 | 164 | this.noButton.connect('clicked', () => { 165 | this._hideConfirm(); 166 | }); 167 | 168 | this.actor.add_child(this.yesButton); 169 | this.actor.add_child(this.noButton); 170 | 171 | } 172 | 173 | _addClosingPrompt() { 174 | this.closingLabel = new St.Label({ 175 | style_class: 'confirm-before-operate', 176 | text: 'Closing open windows ...', 177 | x_expand: false, 178 | x_align: Clutter.ActorAlign.CENTER, 179 | }); 180 | this.actor.add_child(this.closingLabel); 181 | } 182 | 183 | _createButton(iconSymbolic) { 184 | const closeButton = super.createButton(iconSymbolic); 185 | this.actor.add_child(closeButton); 186 | closeButton.connect('clicked', this._onClicked.bind(this)); 187 | } 188 | 189 | _onClicked(button, event) { 190 | // In case someone hide close button again when this.closingLabel is still showing 191 | this._timeline.stop(); 192 | this.closingLabel.hide(); 193 | 194 | this.confirmLabel.show(); 195 | this.showYesAndNoButtons(); 196 | } 197 | 198 | _addConfirm() { 199 | this.confirmLabel = new St.Label({ 200 | style_class: 'confirm-before-operate', 201 | text: 'Confirm?', 202 | x_expand: false, 203 | x_align: Clutter.ActorAlign.START, 204 | }); 205 | this.actor.add_child(this.confirmLabel); 206 | } 207 | 208 | destroy() { 209 | // TODO Nullify others created objects? 210 | 211 | // TODO Also disconnect new-frame and completed? 212 | if (this._timeline) { 213 | this._timeline.stop(); 214 | this._timeline = null; 215 | } 216 | 217 | } 218 | 219 | }); 220 | 221 | 222 | const PopupMenuButtonItemSave = GObject.registerClass( 223 | class PopupMenuButtonItemSave extends PopupMenuButtonItem { 224 | 225 | _init(iconSymbolic) { 226 | super._init(); 227 | this.saveCurrentSessionEntry = null; 228 | this._createButton(iconSymbolic); 229 | this.addIconDescription('Save open windows'); 230 | this._addEntry(); 231 | // Hide this St.Entry, only shown when user click saveButton. 232 | this.saveCurrentSessionEntry.hide(); 233 | this._addYesAndNoButtons(); 234 | 235 | this._log = new Log.Log(); 236 | 237 | this._saveSession = new SaveSession.SaveSession(true); 238 | 239 | this._timeline = this.createTimeLine(); 240 | 241 | this.savingLabel = null; 242 | 243 | this._addSavingPrompt(); 244 | 245 | // Respond to menu item's 'activate' signal so user don't need to click the icon whose size is too small to find to click 246 | this.connect('activate', this._onActivate.bind(this)); 247 | 248 | } 249 | 250 | _addYesAndNoButtons() { 251 | super.createYesAndNoButtons(); 252 | 253 | this.yesButton.connect('clicked', this._onClickedYes.bind(this)); 254 | this.noButton.connect('clicked', () => { 255 | // clear entry 256 | this.saveCurrentSessionEntry.set_text(''); 257 | this.saveCurrentSessionEntry.hide(); 258 | super.hideYesAndNoButtons(); 259 | }); 260 | 261 | this.actor.add_child(this.yesButton); 262 | this.actor.add_child(this.noButton); 263 | 264 | } 265 | 266 | _onClickedYes(button, event) { 267 | this._gotoSaveSession(); 268 | } 269 | 270 | _onActivate() { 271 | this._onClickedBeginSave(); 272 | } 273 | 274 | _addSavingPrompt() { 275 | this.savingLabel = new St.Label({ 276 | style_class: 'confirm-before-operate', 277 | x_expand: false, 278 | x_align: Clutter.ActorAlign.CENTER, 279 | }); 280 | this.actor.add_child(this.savingLabel); 281 | } 282 | 283 | _createButton(iconSymbolic) { 284 | const saveButton = super.createButton(iconSymbolic); 285 | this.actor.add_child(saveButton); 286 | saveButton.connect('clicked', this._onClickedBeginSave.bind(this)); 287 | } 288 | 289 | _onClickedBeginSave(button, event) { 290 | this._timeline.stop(); 291 | this.savingLabel.hide(); 292 | 293 | this.saveCurrentSessionEntry.show(); 294 | this.saveCurrentSessionEntry.grab_key_focus(); 295 | super.showYesAndNoButtons(); 296 | } 297 | 298 | _addEntry() { 299 | this.saveCurrentSessionEntry = new St.Entry({ 300 | name: 'saveCurrentSession', 301 | hint_text: "Type a session name, default is defaultSession", 302 | track_hover: true, 303 | can_focus: true 304 | }); 305 | const clutterText = this.saveCurrentSessionEntry.clutter_text; 306 | clutterText.connect('activate', this._onTextActivate.bind(this)); 307 | this.actor.add_child(this.saveCurrentSessionEntry); 308 | 309 | } 310 | 311 | _onTextActivate(entry, event) { 312 | this._gotoSaveSession(); 313 | } 314 | 315 | _gotoSaveSession() { 316 | let sessionName = this.saveCurrentSessionEntry.get_text(); 317 | if (sessionName) { 318 | // ' ' is truthy 319 | if (!sessionName.trim()) { 320 | sessionName = FileUtils.default_sessionName; 321 | } 322 | } else { 323 | sessionName = FileUtils.default_sessionName; 324 | } 325 | 326 | const [canSave, reason] = this._canSave(sessionName); 327 | if (!canSave) { 328 | this._displayMessage(reason); 329 | return; 330 | } 331 | 332 | // clear entry 333 | this.saveCurrentSessionEntry.set_text(''); 334 | 335 | this.saveCurrentSessionEntry.hide(); 336 | super.hideYesAndNoButtons(); 337 | 338 | this.savingLabel.set_text(`Saving open windows as '${sessionName}' ...`); 339 | this.savingLabel.show(); 340 | 341 | this._saveSession.saveSessionAsync(sessionName).then(() => { 342 | this.savingLabel.hide(); 343 | }).catch(e => { 344 | let message = `Failed to save session`; 345 | this._log.error(e, e.desc ?? message); 346 | global.notify_error(message, e.cause?.message ?? e.desc ?? message); 347 | this._displayMessage(e.cause?.message ?? e.message); 348 | }); 349 | 350 | } 351 | 352 | _displayMessage(message) { 353 | // To prevent saving session many times by holding and not releasing Enter 354 | this.saveCurrentSessionEntry.hide(); 355 | this.savingLabel.set_text(message); 356 | this._timeline.set_actor(this.savingLabel); 357 | const newFrameId = this._timeline.connect('new-frame', (_timeline, _frame) => { 358 | this._timeline.disconnect(newFrameId); 359 | this.savingLabel.show(); 360 | this.hideYesAndNoButtons(); 361 | }); 362 | this._timeline.start(); 363 | const completedId = this._timeline.connect('completed', () => { 364 | this._timeline.disconnect(completedId); 365 | this._timeline.stop(); 366 | this.savingLabel.hide(); 367 | this.saveCurrentSessionEntry.show(); 368 | this.showYesAndNoButtons(); 369 | }); 370 | } 371 | 372 | _canSave(sessionName) { 373 | if (sessionName === FileUtils.sessions_backup_folder_name) { 374 | return [false, `ERROR: ${sessionName} is a reserved word, can't be used.`]; 375 | } 376 | 377 | if (FileUtils.isDirectory(sessionName)) { 378 | return [false, `ERROR: Can't save windows using '${sessionName}', it's an existing directory!`]; 379 | } 380 | 381 | if (sessionName.indexOf('/') != -1) { 382 | return [false, `ERROR: Session names cannot contain '/'`]; 383 | } 384 | return [true, '']; 385 | } 386 | 387 | destroy() { 388 | // TODO Nullify others created objects? 389 | 390 | // TODO Also disconnect new-frame and completed? 391 | if (this._timeline) { 392 | this._timeline.stop(); 393 | this._timeline = null; 394 | } 395 | 396 | } 397 | 398 | 399 | }); -------------------------------------------------------------------------------- /ui/searchSessionItem.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import GObject from 'gi://GObject'; 4 | import St from 'gi://St'; 5 | import Clutter from 'gi://Clutter'; 6 | 7 | import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; 8 | 9 | import * as Tooltip from '../utils/tooltip.js'; 10 | 11 | 12 | export const SearchSessionItem = GObject.registerClass( 13 | class SearchSessionItem extends PopupMenu.PopupBaseMenuItem { 14 | 15 | _init() { 16 | super._init({ 17 | activate: false, 18 | reactive: true, 19 | hover: false, 20 | can_focus: false 21 | }); 22 | 23 | this._entry = new St.Entry({ 24 | name: 'searchEntry', 25 | style_class: 'search-entry', 26 | can_focus: true, 27 | hint_text: _('Type to search'), 28 | track_hover: true, 29 | x_expand: false, 30 | y_expand: true 31 | }); 32 | 33 | this._entry.set_primary_icon(new St.Icon({ 34 | style_class: 'search-entry-icon', 35 | icon_name: 'edit-find-symbolic' 36 | })); 37 | 38 | this.add_child(this._entry); 39 | 40 | this._clearIcon = new St.Icon({ 41 | style_class: 'search-entry-icon', 42 | icon_name: 'edit-clear-symbolic' 43 | }); 44 | 45 | this._entry.set_secondary_icon(this._clearIcon); 46 | this._secondaryIconClickedId = this._entry.connect('secondary-icon-clicked', this.reset.bind(this)); 47 | 48 | this._addFilters(); 49 | } 50 | 51 | _addFilters() { 52 | const filterLabel = new St.Label({ 53 | text: 'Filter: ', 54 | x_align: Clutter.ActorAlign.CENTER, 55 | y_align: Clutter.ActorAlign.CENTER, 56 | }); 57 | this.add_child(filterLabel); 58 | this._filterAutoRestore(); 59 | 60 | } 61 | 62 | _filterAutoRestore() { 63 | this._filterAutoRestoreSwitch = new PopupMenu.Switch(false); 64 | this._filterAutoRestoreSwitch.set_style_class_name('toggle-switch awsm-toggle-switch'); 65 | let button = new St.Button({ 66 | style_class: 'dnd-button', 67 | can_focus: true, 68 | x_align: Clutter.ActorAlign.END, 69 | toggle_mode: true, 70 | child: this._filterAutoRestoreSwitch, 71 | }); 72 | this._filterAutoRestoreSwitch.bind_property('state', 73 | button, 'checked', 74 | GObject.BindingFlags.BIDIRECTIONAL | GObject.BindingFlags.SYNC_CREATE); 75 | 76 | new Tooltip.Tooltip({ 77 | parent: button, 78 | markup: 'Show only auto-restore item(s)', 79 | }); 80 | 81 | this.add_child(button); 82 | } 83 | 84 | reset() { 85 | this._entry.grab_key_focus(); 86 | this._entry.set_text(''); 87 | let text = this._entry.get_clutter_text(); 88 | text.set_cursor_visible(true); 89 | } 90 | 91 | destroy() { 92 | if (this._secondaryIconClickedId) { 93 | this._entry.disconnect(this._secondaryIconClickedId); 94 | this._secondaryIconClickedId = null; 95 | } 96 | } 97 | }); -------------------------------------------------------------------------------- /ui/sessionItem.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import GObject from 'gi://GObject'; 4 | 5 | import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; 6 | 7 | import * as SessionItemButtons from '../ui/sessionItemButtons.js'; 8 | 9 | 10 | export const SessionItem = GObject.registerClass( 11 | class SessionItem extends PopupMenu.PopupMenuItem { 12 | 13 | _init(fileInfo, file, indicator) { 14 | // Initialize this component, so we can use this.label etc 15 | super._init(""); 16 | 17 | this._indicator = indicator; 18 | 19 | this._available = true; 20 | 21 | this._filepath = file.get_path(); 22 | if(fileInfo != null) { 23 | this._filename = fileInfo.get_name(); 24 | const modification_date_time = fileInfo.get_modification_date_time(); 25 | if (modification_date_time) { 26 | this._modification_time = modification_date_time.to_local().format('%Y-%m-%d %T'); 27 | } else { 28 | this._modification_time = '( Unknown )'; 29 | this._available = false; 30 | } 31 | } else { 32 | this._filename = file.get_basename(); 33 | this._modification_time = '( Please save this session before using it )'; 34 | 35 | this._available = false; 36 | } 37 | 38 | this.label.set_x_expand(true); 39 | this.label.clutter_text.set_text(this._filename); 40 | 41 | this._sessionItemButtons = new SessionItemButtons.SessionItemButtons(this); 42 | this._sessionItemButtons.addButtons(); 43 | 44 | } 45 | 46 | 47 | 48 | }); 49 | 50 | const EmptySessionItem = GObject.registerClass( 51 | class EmptySessionItem extends PopupMenu.PopupMenuItem { 52 | 53 | _init() { 54 | super._init("(Empty, please save open windows first)"); 55 | this.setSensitive(false); 56 | } 57 | 58 | }); 59 | 60 | -------------------------------------------------------------------------------- /ui/sessionItemButtons.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import GObject from 'gi://GObject'; 4 | import St from 'gi://St'; 5 | import GLib from 'gi://GLib'; 6 | import Clutter from 'gi://Clutter'; 7 | 8 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 9 | import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; 10 | 11 | import * as IconFinder from '../utils/iconFinder.js'; 12 | import * as FileUtils from '../utils/fileUtils.js'; 13 | import * as DateUtils from '../utils/dateUtils.js'; 14 | import * as Tooltip from '../utils/tooltip.js'; 15 | import * as Log from '../utils/log.js'; 16 | import {PrefsUtils} from '../utils/prefsUtils.js'; 17 | 18 | import * as SaveSession from '../saveSession.js'; 19 | import * as RestoreSession from '../restoreSession.js'; 20 | import * as MoveSession from '../moveSession.js'; 21 | import * as CloseSession from '../closeSession.js'; 22 | import * as Constants from '../constants.js'; 23 | 24 | import {Button} from './button.js'; 25 | 26 | import * as Autoclose from './autoclose.js'; 27 | 28 | 29 | export const SessionItemButtons = GObject.registerClass( 30 | class SessionItemButtons extends GObject.Object { 31 | 32 | _init(sessionItem) { 33 | super._init(); 34 | 35 | this._log = new Log.Log(); 36 | 37 | this.sessionItem = sessionItem; 38 | 39 | // TODO Nullify created object? 40 | this._saveSession = new SaveSession.SaveSession(true); 41 | this._moveSession = new MoveSession.MoveSession(); 42 | this._closeSession = new CloseSession.CloseSession(CloseSession.flags.closeWindows); 43 | 44 | this._settings = PrefsUtils.getSettings(); 45 | } 46 | 47 | addButtons() { 48 | this._addTags(); 49 | 50 | const saveButton = this._addButton('save-symbolic.svg'); 51 | new Tooltip.Tooltip({ 52 | parent: saveButton, 53 | markup: 'Save open windows using the current session name', 54 | }); 55 | saveButton.connect('clicked', this._onClickSave.bind(this)); 56 | 57 | const restoreButton = this._addButton('restore-symbolic.svg'); 58 | restoreButton.set_reactive(this.sessionItem._available); 59 | new Tooltip.Tooltip({ 60 | parent: restoreButton, 61 | markup: 'Restore windows from the saved session', 62 | }); 63 | restoreButton.connect('clicked', this._onClickRestore.bind(this)); 64 | 65 | const moveButton = this._addButton('move-symbolic.svg'); 66 | moveButton.set_reactive(this.sessionItem._available); 67 | new Tooltip.Tooltip({ 68 | parent: moveButton, 69 | markup: 'Move windows to their workspace by the saved session', 70 | }); 71 | moveButton.connect('clicked', this._onClickMove.bind(this)); 72 | 73 | // this._addSeparator(); 74 | 75 | // const closeButton = this._addButton('close-symbolic.svg'); 76 | // closeButton.connect('clicked', this._onClickClose.bind(this)); 77 | 78 | const autoRestoreSwitcher = this._addAutostartSwitcher(); 79 | new Tooltip.Tooltip({ 80 | parent: autoRestoreSwitcher, 81 | markup: 'Restore at startup', 82 | }); 83 | autoRestoreSwitcher.connect('clicked', (button, event) => { 84 | const state = this._autostartSwitch.state; 85 | if (state) { 86 | this._settings.set_string(Constants.PREFS_SETTING_AUTORESTORE_SESSIONS, this.sessionItem._filename); 87 | } else { 88 | this._settings.set_string(Constants.PREFS_SETTING_AUTORESTORE_SESSIONS, ''); 89 | } 90 | }); 91 | 92 | this._settings.connect(`changed::${Constants.PREFS_SETTING_AUTORESTORE_SESSIONS}`, (settings) => { 93 | const toggled = this.sessionItem._filename == this._settings.get_string(Constants.PREFS_SETTING_AUTORESTORE_SESSIONS); 94 | this._autostartSwitch.state = toggled; 95 | }); 96 | 97 | this._addSeparator(); 98 | 99 | const viewButton = this._addViewButton(); 100 | new Tooltip.Tooltip({ 101 | parent: viewButton, 102 | markup: 'Open session file using an external editor', 103 | }); 104 | viewButton.connect('clicked', () => { 105 | const sessions_path = FileUtils.get_sessions_path(); 106 | const session_file_path = GLib.build_filenamev([sessions_path, this.sessionItem._filename]); 107 | FileUtils.findDefaultApp(session_file_path).then(([app, file]) => { 108 | try { 109 | app.launch([file], global.create_app_launch_context(DateUtils.get_current_time(), -1)); 110 | } catch (error) { 111 | this._log.error(error, `Failed to open ${session_file_path} using ${app.get_filename()}`); 112 | } 113 | }).catch(error => { 114 | this._log.error(error, `Failed to find the default application to ${session_file_path}`); 115 | }); 116 | }); 117 | 118 | const deleteButton = this._addDeleteButton(); 119 | new Tooltip.Tooltip({ 120 | parent: deleteButton, 121 | markup: 'Move to Trash', 122 | }); 123 | deleteButton.connect('clicked', () => { 124 | // We just trash file to trash scan instead of delete in case still need it. 125 | FileUtils.trashSession(this.sessionItem._filename); 126 | }); 127 | 128 | } 129 | 130 | _addAutostartSwitcher() { 131 | 132 | const toggled = this.sessionItem._filename == this._settings.get_string(Constants.PREFS_SETTING_AUTORESTORE_SESSIONS); 133 | this._autostartSwitch = new PopupMenu.Switch(toggled); 134 | this._autostartSwitch.set_style_class_name('toggle-switch awsm-toggle-switch'); 135 | let button = new St.Button({ 136 | style_class: 'dnd-button', 137 | can_focus: true, 138 | x_align: Clutter.ActorAlign.END, 139 | toggle_mode: true, 140 | child: this._autostartSwitch, 141 | reactive: this.sessionItem._available 142 | }); 143 | this._autostartSwitch.bind_property('state', 144 | button, 'checked', 145 | GObject.BindingFlags.BIDIRECTIONAL | GObject.BindingFlags.SYNC_CREATE); 146 | this.sessionItem.actor.add_child(button); 147 | return button; 148 | } 149 | 150 | _addViewButton() { 151 | const [exists, sessionFilePath] = FileUtils.sessionExists(this.sessionItem._filename); 152 | return this._addTextButton('View', exists); 153 | } 154 | 155 | _addDeleteButton() { 156 | const reactive = this.sessionItem._filename != FileUtils.recently_closed_session_name; 157 | return this._addTextButton('Delete', reactive); 158 | } 159 | 160 | _addTextButton(label, reactive) { 161 | let button = new St.Button({ 162 | style_class: 'button', 163 | can_focus: true, 164 | x_align: Clutter.ActorAlign.END, 165 | x_expand: false, 166 | y_expand: true, 167 | track_hover: true, 168 | reactive: reactive, 169 | }); 170 | button.set_label(label); 171 | this.sessionItem.actor.add_child(button); 172 | return button; 173 | } 174 | 175 | _addTags() { 176 | if (!Log.Log.getDefault().isDebug()) return; 177 | 178 | // TODO Make the modification time align left 179 | 180 | let button = new St.Button({ 181 | x_align: Clutter.ActorAlign.END, 182 | }); 183 | 184 | button.set_label(this.sessionItem._modification_time); 185 | if (!this.sessionItem._available) { 186 | button.set_style('color: red;'); 187 | } 188 | this.sessionItem.actor.add_child(button); 189 | 190 | this._addSeparator(); 191 | } 192 | 193 | _addSeparator() { 194 | let icon = new St.Icon({ 195 | gicon: IconFinder.find('separator-symbolic.svg'), 196 | style_class: 'system-status-icon' 197 | }); 198 | 199 | let button = new St.Button({ 200 | style_class: 'aws-item-separator', 201 | can_focus: false, 202 | child: icon, 203 | x_align: Clutter.ActorAlign.END, 204 | x_expand: false, 205 | y_expand: false, 206 | track_hover: false 207 | }); 208 | 209 | this.sessionItem.actor.add_child(button); 210 | } 211 | 212 | _addButton(iconSymbolic) { 213 | const button = new Button({ 214 | icon_symbolic: iconSymbolic, 215 | }).button; 216 | this.sessionItem.actor.add_child(button); 217 | return button; 218 | } 219 | 220 | _onClickSave(button, event) { 221 | this._saveSession.saveSessionAsync(this.sessionItem._filename).catch(e => { 222 | let message = `Failed to save session`; 223 | this._log.error(e, e.desc ?? message); 224 | global.notify_error(message, e.cause?.message ?? e.desc ?? message); 225 | }); 226 | } 227 | 228 | _onClickRestore(button, event) { 229 | Autoclose.autocloseObject.sessionClosedByUser = false; 230 | RestoreSession.restoreSessionObject.restoringApps = new Map(); 231 | // Using _restoredApps to hold restored apps so we create new instance every time for now 232 | const _restoreSession = new RestoreSession.RestoreSession(); 233 | _restoreSession.restoreSession(this.sessionItem._filename); 234 | } 235 | 236 | _onClickMove(button, event) { 237 | this._moveSession.moveWindows(this.sessionItem._filename); 238 | } 239 | 240 | _onClickClose(button, event) { 241 | // TODO Close specified windows in the session? 242 | this._closeSession.closeWindows(); 243 | } 244 | }); -------------------------------------------------------------------------------- /ui/uiHelper.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import Meta from 'gi://Meta'; 4 | 5 | 6 | export function isDialog(metaWindow) { 7 | const dialogTypes = [ 8 | // 3 9 | Meta.WindowType.DIALOG, 10 | // 4 11 | Meta.WindowType.MODAL_DIALOG, 12 | ]; 13 | const winType = metaWindow.get_window_type(); 14 | return dialogTypes.includes(winType) && 15 | metaWindow.get_transient_for() != null; 16 | } 17 | 18 | export function ignoreWindows(metaWindow) { 19 | if (isDialog(metaWindow)) { 20 | return true; 21 | } 22 | 23 | // The override-redirect windows is invisible to the users, 24 | // and the workspace index is -1 and don't have proper x, y, width, height. 25 | // See also: 26 | // https://gjs-docs.gnome.org/meta9~9_api/meta.window#method-is_override_redirect 27 | // https://wiki.tcl-lang.org/page/wm+overrideredirect 28 | // https://docs.oracle.com/cd/E36784_01/html/E36843/windowapi-3.html 29 | // https://stackoverflow.com/questions/38162932/what-does-overrideredirect-do 30 | // https://ml.cddddr.org/cl-windows/msg00166.html 31 | if (metaWindow.is_override_redirect()) { 32 | return true; 33 | } 34 | 35 | return false; 36 | } -------------------------------------------------------------------------------- /utils/CommonError.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * 4 | * Create a customized Error with error description 5 | * 6 | * Usage: 7 | * ```js 8 | * const myError = new BaseError('A message', { 9 | * cause: new Error('Caused by another error'), 10 | * desc: "A description" 11 | * }); 12 | * ``` 13 | * 14 | * @param message 15 | * @param options 16 | */ 17 | export const CommonError = class extends Error{ 18 | 19 | constructor(message, options = {}) { 20 | if (!options.cause) { 21 | delete options.cause; 22 | } 23 | super(message, options); 24 | this.desc = options.desc; 25 | } 26 | 27 | } -------------------------------------------------------------------------------- /utils/WindowPicker.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: nlpsuge 2 | // SPDX-FileCopyrightText: Simon Schneegans 3 | // SPDX-FileCopyrightText: Aurélien Hamy 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | 'use strict'; 7 | 8 | import Clutter from 'gi://Clutter'; 9 | import GObject from 'gi://GObject'; 10 | import Gio from 'gi://Gio'; 11 | import GLib from 'gi://GLib'; 12 | import Shell from 'gi://Shell'; 13 | 14 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 15 | import * as LookingGlass from 'resource:///org/gnome/shell/ui/lookingGlass.js'; 16 | 17 | import * as FileUtils from './fileUtils.js'; 18 | 19 | // Based on the WindowPicker.js from Burn-My-Windows. 20 | // I modified and enhanced it, so it can be used in my case 21 | // properly for the Another Window Session Manager extension. 22 | 23 | 24 | ////////////////////////////////////////////////////////////////////////////////////////// 25 | // This is based on the window-picking functionality of the Blur-My-Shell extension. // 26 | // The PickWindow() method is exposed via the D-Bus and can be called by the // 27 | // preferences dialog of the Burn-My-Windows extensions in order to initiate the window // 28 | // picking. // 29 | ////////////////////////////////////////////////////////////////////////////////////////// 30 | 31 | export const WindowPickerServiceProvider = class WindowPickerServiceProvider { 32 | // ------------------------------------------------------------------------- constructor 33 | 34 | constructor() { 35 | const iFace = new TextDecoder().decode( 36 | FileUtils.current_extension_dir.get_child('dbus-interfaces').get_child('org.gnome.Shell.Extensions.awsm.PickWindow.xml').load_contents(null)[1]); 37 | this._dbus = Gio.DBusExportedObject.wrapJSObject(iFace, this); 38 | } 39 | 40 | // --------------------------------------------------------------------- D-Bus interface 41 | 42 | // This method is exposed via the D-Bus. It is called by the preferences dialog of the 43 | // Burn-My-Windows extensions in order to initiate the window picking. 44 | PickWindow() { 45 | 46 | // We use the actor picking from LookingGlass. This seems a bit hacky and also allows 47 | // selecting things of the Shell which are not windows, but it does the trick :) 48 | const lookingGlass = Main.createLookingGlass(); 49 | lookingGlass.open(); 50 | lookingGlass.hide(); 51 | 52 | const inspector = new MyInspector(Main.createLookingGlass()); 53 | 54 | Main.popModal(lookingGlass._grab); 55 | 56 | inspector.connect('target', (me, target, x, y) => { 57 | // Remove border effect when window is picked. 58 | target.get_effects() 59 | .filter(e => e.toString().includes('lookingGlass_RedBorderEffect')) 60 | .forEach(e => target.remove_effect(e)); 61 | 62 | // While we may switch windows to pick a window, the target actor also changes. 63 | // Here we check the current actor again, make sure it's what we except. 64 | let currentActor = global.stage.get_actor_at_pos(Clutter.PickMode.ALL, x, y); 65 | if (currentActor != target) { 66 | log('Picked window changed to ' + currentActor); 67 | target = currentActor; 68 | } 69 | 70 | let actor = target; 71 | if (target.toString().includes('MetaSurfaceActor')) { 72 | actor = target.get_parent(); 73 | } 74 | 75 | let variant; 76 | if (actor.toString().includes('WindowActor')) { 77 | const metaWindow = actor.meta_window; 78 | const app = Shell.WindowTracker.get_default().get_window_app(metaWindow); 79 | const appName = app ? app.get_name() : ''; 80 | const wmClass = metaWindow.get_wm_class(); 81 | const wmClassInstance = metaWindow.get_wm_class_instance(); 82 | const title = metaWindow.get_title(); 83 | const result = [ 84 | appName, 85 | wmClass ? wmClass : '', 86 | wmClassInstance ? wmClassInstance : '', 87 | title ? title : '', 88 | ]; 89 | variant = new GLib.Variant('(ssss)', result) 90 | } else { 91 | variant = new GLib.Variant('()', []); 92 | } 93 | 94 | this._dbus.emit_signal('WindowPicked', variant); 95 | }); 96 | 97 | // Close LookingGlass and release the grab when the picking is finished. 98 | inspector.connect('closed', () => { 99 | // Restore the global grab to prevent the error 'incorrect pop' thrown by LookingGlass.close/Main.popModal(this._grab) 100 | lookingGlass._grab = Main.pushModal(lookingGlass, { actionMode: Shell.ActionMode.LOOKING_GLASS }); 101 | lookingGlass.close(); 102 | }); 103 | 104 | inspector.connect('WindowPickCancelled', () => { 105 | this._dbus.emit_signal('WindowPickCancelled', null); 106 | }); 107 | } 108 | 109 | // -------------------------------------------------------------------- public interface 110 | 111 | // Call this to make the window-picking API available on the D-Bus. 112 | enable() { 113 | this._dbus.export(Gio.DBus.session, '/org/gnome/shell/extensions/awsm'); 114 | } 115 | 116 | // Call this to stop this D-Bus again. 117 | destroy() { 118 | this._dbus.unexport(); 119 | } 120 | }; 121 | 122 | const MyInspector = GObject.registerClass({ 123 | Signals: { 124 | 'WindowPickCancelled': {} 125 | } 126 | }, class MyInspector extends LookingGlass.Inspector { 127 | _init(lookingGlass) { 128 | super._init(lookingGlass); 129 | } 130 | 131 | _onKeyPressEvent(actor, event) { 132 | if (event.get_key_symbol() === Clutter.KEY_Escape) { 133 | this.emit('WindowPickCancelled'); 134 | this._close(); 135 | } 136 | return Clutter.EVENT_STOP; 137 | } 138 | }); -------------------------------------------------------------------------------- /utils/dateUtils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Get the current timestamp through `global.get_current_time()`, but it might return 0, 5 | * which is not a valid value for some function such as `metaWindow.delete(timestamp)`, 6 | * will get a warning if pass 0 to it. 7 | * 8 | * The doc states "If called from outside an event handler, this may return 9 | * %Clutter.CURRENT_TIME (aka 0), or it may return a slightly out-of-date timestamp." 10 | * 11 | * If so we use `global.display.get_current_time_roundtrip()` to get a valid timestamp. 12 | * 13 | * On Wayland, `global.display.get_current_time_roundtrip()` also uses 14 | * `Number.parseInt(GLib.get_monotonic_time() / 1000)` to get the current timestamp. 15 | * 16 | * @returns guint32 type timestamp, for example 75176468 17 | */ 18 | export const get_current_time = function() { 19 | return global.get_current_time() || global.display.get_current_time_roundtrip(); 20 | } -------------------------------------------------------------------------------- /utils/fileUtils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import Gio from 'gi://Gio'; 4 | import GLib from 'gi://GLib'; 5 | 6 | import * as Log from './log.js'; 7 | 8 | 9 | export let current_extension_path = null; 10 | export let current_extension_dir = null; 11 | 12 | export const default_sessionName = 'defaultSession'; 13 | export const data_dir = GLib.get_user_data_dir(); 14 | export const user_config = GLib.get_user_config_dir(); 15 | // This extension can restore `xsm`'s session file, 16 | // but desktop_file_id is missing in that file, so can't move them. Will be fixed in the future. 17 | export const config_path_base = GLib.build_filenamev([user_config, 'another-window-session-manager']); 18 | // The session list 19 | export const sessions_path = GLib.build_filenamev([config_path_base, 'sessions']); 20 | export const sessions_backup_folder_name = 'backups'; 21 | const sessions_backup_path = GLib.build_filenamev([sessions_path, sessions_backup_folder_name]); 22 | 23 | export let desktop_template_path = null; 24 | export let desktop_template_path_restore_at_autostart = null; 25 | export let desktop_template_path_restore_previous_at_autostart = null; 26 | export let desktop_template_launch_app_shell_script = null; 27 | 28 | export const desktop_file_store_path_base = GLib.build_filenamev([data_dir, '/applications']); 29 | export const desktop_file_store_path = `${desktop_file_store_path_base}/__another-window-session-manager`; 30 | 31 | export const recently_closed_session_name = 'Recently Closed Session'; 32 | export const recently_closed_session_path = GLib.build_filenamev([sessions_path, recently_closed_session_name]); 33 | export const recently_closed_session_file = Gio.File.new_for_path(recently_closed_session_path); 34 | 35 | export const current_session_path = `${config_path_base}/currentSession`; 36 | 37 | export const current_session_summary_name = 'summary.json'; 38 | export const current_session_summary_path = GLib.build_filenamev([current_session_path, 'summary.json']); 39 | 40 | export const autostart_restore_desktop_file_path = GLib.build_filenamev([user_config, '/autostart/_gnome-shell-extension-another-window-session-manager.desktop']); 41 | export const autostart_restore_previous_desktop_file_path = GLib.build_filenamev([user_config, '/autostart/_awsm-restore-previous-session.desktop']); 42 | 43 | export let desktop_template_path_ydotool_uinput_rules; 44 | export const system_udev_rules_path_ydotool_uinput_rules = '/etc/udev/rules.d/60-awsm-ydotool-uinput.rules'; 45 | 46 | // Some constants rely on extension metadata, 47 | // we put them all here and initialize them from extension.js 48 | export function init(extensionObject) { 49 | current_extension_dir = extensionObject.dir; 50 | current_extension_path = extensionObject.path; 51 | desktop_template_path = GLib.build_filenamev([extensionObject.path, '/template/template.desktop']); 52 | desktop_template_path_restore_at_autostart = GLib.build_filenamev([extensionObject.path, '/template/_gnome-shell-extension-another-window-session-manager.desktop']); 53 | desktop_template_path_restore_previous_at_autostart = GLib.build_filenamev([extensionObject.path, '/template/_awsm-restore-previous-session.desktop']); 54 | desktop_template_launch_app_shell_script = GLib.build_filenamev([extensionObject.path, '/template/launch-app.sh']); 55 | desktop_template_path_ydotool_uinput_rules = GLib.build_filenamev([extensionObject.path, '/template/60-awsm-ydotool-uinput.rules']); 56 | 57 | } 58 | 59 | export async function loadSummary() { 60 | try { 61 | return await loadFile(current_session_summary_path); 62 | } catch (error) { 63 | Log.Log.getDefault().error(error); 64 | } 65 | } 66 | 67 | export async function loadFile(path) { 68 | try { 69 | return new Promise((resolve, reject) => { 70 | const file = Gio.File.new_for_path(path); 71 | file.load_contents_async( 72 | null, 73 | (file, asyncResult) => { 74 | try { 75 | const [success, contents, _] = file.load_contents_finish(asyncResult); 76 | resolve([getJsonObj(contents), path]); 77 | } catch (error) { 78 | Log.Log.getDefault().error(error); 79 | reject(error); 80 | } 81 | }); 82 | }); 83 | } catch (error) { 84 | Log.Log.getDefault().error(error); 85 | } 86 | } 87 | 88 | /** 89 | * Get the absolute session path which contains sessions, 90 | * it's `~/.config/another-window-session-manager` by default. 91 | * 92 | * @param {string} baseDir base directory, `~/.config/another-window-session-manager/sessions` by default 93 | * @returns {string} the absolute session path which contains sessions 94 | */ 95 | export function get_sessions_path(baseDir = null) { 96 | if (baseDir) { 97 | return baseDir; 98 | } else { 99 | return sessions_path; 100 | } 101 | } 102 | 103 | export function get_sessions_backups_path() { 104 | return sessions_backup_path; 105 | } 106 | 107 | export function getJsonObj(contents) { 108 | let session_config; 109 | // Fix Gnome 3 crash due to: Some code called array.toString() on a Uint8Array instance. Previously this would have interpreted the bytes of the array as a string, but that is nonstandard. In the future this will return the bytes as comma-separated digits. For the time being, the old behavior has been preserved, but please fix your code anyway to explicitly call new TextDecoder().decode(array). 110 | if (contents instanceof Uint8Array) { 111 | const contentsConverted = new TextDecoder().decode(contents); 112 | session_config = JSON.parse(contentsConverted); 113 | } else { 114 | // Unreachable code 115 | session_config = JSON.parse(contents); 116 | } 117 | return session_config; 118 | } 119 | 120 | export async function listAllSessions(sessionPath, recursion, callback) { 121 | try { 122 | if (!sessionPath) { 123 | sessionPath = get_sessions_path(); 124 | } 125 | if (!GLib.file_test(sessionPath, GLib.FileTest.EXISTS)) { 126 | Log.Log.getDefault().warn(`${sessionPath} not exist`); 127 | return; 128 | } 129 | 130 | Log.Log.getDefault().debug(`Scanning ${sessionPath}`); 131 | 132 | const sessionPathFile = Gio.File.new_for_path(sessionPath); 133 | let fileEnumerator = await new Promise((resolve, reject) => { 134 | sessionPathFile.enumerate_children_async( 135 | [Gio.FILE_ATTRIBUTE_STANDARD_NAME, 136 | Gio.FILE_ATTRIBUTE_STANDARD_TYPE, 137 | Gio.FILE_ATTRIBUTE_TIME_MODIFIED, 138 | Gio.FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE].join(','), 139 | Gio.FileQueryInfoFlags.NONE, 140 | GLib.PRIORITY_DEFAULT, 141 | null, 142 | (file, asyncResult) => { 143 | try { 144 | resolve(file.enumerate_children_finish(asyncResult)); 145 | } catch (e) { 146 | Log.Log.getDefault().error(e, `Failed to list directory ${sessionPath}`); 147 | reject(e); 148 | } 149 | }); 150 | }); 151 | 152 | const nextFilesFunc = async () => { 153 | return new Promise((resolve, reject) => { 154 | fileEnumerator.next_files_async( 155 | // num_files. Just set a random value, because I don't know which value is better yet 156 | 10, 157 | GLib.PRIORITY_DEFAULT, 158 | null, 159 | (iter, asyncResult) => { 160 | try { 161 | resolve(iter.next_files_finish(asyncResult)); 162 | } catch (e) { 163 | reject(e); 164 | } 165 | } 166 | ); 167 | }); 168 | }; 169 | 170 | let infos = await nextFilesFunc(); 171 | while (infos && infos.length) { 172 | for (const info of infos) { 173 | const file = fileEnumerator.get_child(info); 174 | if (recursion && info.get_file_type() === Gio.FileType.DIRECTORY) { 175 | await listAllSessions(file.get_path(), recursion, callback); 176 | } 177 | 178 | if (callback) { 179 | callback(file, info); 180 | } 181 | } 182 | 183 | infos = await nextFilesFunc(); 184 | } 185 | } catch (e) { 186 | Log.Log.getDefault().error(e); 187 | } 188 | } 189 | 190 | export function sessionExists(sessionName, baseDir = null) { 191 | const sessionsPath = get_sessions_path(baseDir); 192 | const sessionFilePath = GLib.build_filenamev([sessionsPath, sessionName]); 193 | if (GLib.file_test(sessionFilePath, GLib.FileTest.EXISTS)) { 194 | return [true, sessionFilePath]; 195 | } 196 | return [false]; 197 | } 198 | 199 | /** 200 | * Remove files. And also remove its parent if it's empty. 201 | * 202 | * @param {String} path The path of a file or a directory 203 | */ 204 | export function removeFileAndParent(path) { 205 | if (!GLib.file_test(path, GLib.FileTest.EXISTS)) { 206 | throw new Error(`Cannot remove '${path}': No such file or directory`); 207 | } 208 | 209 | const file = Gio.File.new_for_path(path); 210 | try { 211 | const info = file.query_info( 212 | [Gio.FILE_ATTRIBUTE_STANDARD_TYPE].join(','), 213 | Gio.FileQueryInfoFlags.NONE, 214 | null); 215 | 216 | const fileType = info.get_file_type(); 217 | const isDir = fileType === Gio.FileType.DIRECTORY; 218 | 219 | file.delete(null); 220 | Log.Log.getDefault().debug(`Removed ${isDir ? 'directory' : ''} ${path}`); 221 | 222 | const parent = file.get_parent(); 223 | if (parent && isEmpty(parent)) { 224 | parent.delete(null); 225 | Log.Log.getDefault().debug(`Removed directory ${parent.get_path()}`); 226 | } 227 | 228 | } catch (e) { 229 | Log.Log.getDefault().error(e); 230 | } 231 | } 232 | 233 | export function isEmpty(directory) { 234 | const fileEnumerator = directory.enumerate_children( 235 | [Gio.FILE_ATTRIBUTE_STANDARD_NAME, 236 | Gio.FILE_ATTRIBUTE_STANDARD_TYPE].join(','), 237 | Gio.FileQueryInfoFlags.NONE, 238 | null); 239 | return !fileEnumerator.next_file(null); 240 | } 241 | 242 | /** 243 | * Remove files or directories 244 | * 245 | * @param {String} path The path of a file or a directory 246 | * @param {Boolean} recursively true if remove all files or directories in `path` 247 | */ 248 | export function removeFile(path, recursively = false) { 249 | if (!GLib.file_test(path, GLib.FileTest.EXISTS)) { 250 | throw new Error(`Cannot remove '${path}': No such file or directory`); 251 | } 252 | 253 | const file = Gio.File.new_for_path(path); 254 | try { 255 | const info = file.query_info( 256 | [Gio.FILE_ATTRIBUTE_STANDARD_TYPE].join(','), 257 | Gio.FileQueryInfoFlags.NONE, 258 | null); 259 | 260 | const fileType = info.get_file_type(); 261 | if (fileType === Gio.FileType.DIRECTORY) { 262 | if (!recursively) { 263 | throw new Error(`Cannot remove '${path}': Is a directory`); 264 | } 265 | const fileEnumerator = file.enumerate_children( 266 | [Gio.FILE_ATTRIBUTE_STANDARD_NAME, 267 | Gio.FILE_ATTRIBUTE_STANDARD_TYPE].join(','), 268 | Gio.FileQueryInfoFlags.NONE, 269 | null); 270 | 271 | let fileInfo = null; 272 | while (fileInfo = fileEnumerator.next_file(null)) { 273 | const childFile = fileEnumerator.get_child(fileInfo); 274 | if (info.get_file_type() === Gio.FileType.DIRECTORY) { 275 | removeFile(childFile.get_path(), recursively); 276 | } 277 | } 278 | 279 | file.delete(null); 280 | Log.Log.getDefault().debug(`Removed directory ${path}`); 281 | } else { 282 | file.delete(null); 283 | Log.Log.getDefault().debug(`Removed ${path}`); 284 | } 285 | } catch (e) { 286 | Log.Log.getDefault().error(e); 287 | } 288 | } 289 | 290 | export function trashSession(sessionName) { 291 | const [exists, sessionFilePath] = sessionExists(sessionName); 292 | if (!exists) { 293 | return true; 294 | } 295 | 296 | let trashed = false; 297 | try { 298 | const sessionPathFile = Gio.File.new_for_path(sessionFilePath); 299 | trashed = sessionPathFile.trash(null); 300 | if (!trashed) { 301 | Log.Log.getDefault().error(new Error(`Failed to trash file ${sessionFilePath}. Reason: Unknown.`)); 302 | } 303 | return trashed; 304 | } catch (e) { 305 | Log.Log.getDefault().error(e, `Failed to trash file ${sessionFilePath}`); 306 | return false; 307 | } 308 | } 309 | 310 | export function isDirectory(sessionName) { 311 | const sessionFilePath = GLib.build_filenamev([sessions_path, sessionName]); 312 | if (GLib.file_test(sessionFilePath, GLib.FileTest.IS_DIR)) { 313 | return true; 314 | } 315 | 316 | return false; 317 | } 318 | 319 | export function loadAutostartDesktopTemplate() { 320 | return loadTemplate(desktop_template_path_restore_at_autostart); 321 | } 322 | 323 | export function loadDesktopTemplate(cancellable = null) { 324 | return loadTemplate(desktop_template_path, cancellable); 325 | } 326 | 327 | export function loadTemplate(path, cancellable = null) { 328 | const desktop_template_file = Gio.File.new_for_path(path); 329 | let [success, contents] = desktop_template_file.load_contents(cancellable); 330 | if (success) { 331 | if (contents instanceof Uint8Array) { 332 | return new TextDecoder().decode(contents); 333 | } else { 334 | // Unreachable code 335 | return contents; 336 | } 337 | } 338 | 339 | return ''; 340 | } 341 | 342 | /** 343 | * Find the default app to open session file 344 | * 345 | * @param {string} filePath 346 | */ 347 | export function findDefaultApp(filePath) { 348 | const session_file = Gio.File.new_for_path(filePath); 349 | return new Promise((resolve, reject) => { 350 | session_file.query_default_handler_async( 351 | GLib.PRIORITY_DEFAULT, 352 | null, 353 | (file, asyncResult) => { 354 | try { 355 | const app = session_file.query_default_handler_finish(asyncResult); 356 | if (app) { 357 | resolve([app, session_file]); 358 | } else { 359 | reject(new Error(`Cannot find the default application to ${filePath}`)); 360 | } 361 | } catch (error) { 362 | reject(error); 363 | } 364 | }); 365 | }); 366 | } 367 | -------------------------------------------------------------------------------- /utils/function.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | export const callFunc = function (thisObj, func, param) { 5 | try { 6 | if (!(param instanceof Array)) { 7 | if (param) { 8 | return func.call(thisObj, param); 9 | } 10 | return func.call(thisObj); 11 | } 12 | return func.call(thisObj, ...param); 13 | } catch (error) { 14 | logError(error); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /utils/iconFinder.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import Gio from 'gi://Gio'; 4 | import GLib from 'gi://GLib'; 5 | 6 | import * as FileUtils from './fileUtils.js'; 7 | 8 | // import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; 9 | let Extension; 10 | try { 11 | let extensionObj = await import('resource:///org/gnome/shell/extensions/extension.js'); 12 | Extension = extensionObj.Extension; 13 | } catch (e) { 14 | let extensionPrefsObj = await import('resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'); 15 | Extension = extensionPrefsObj.ExtensionPreferences; 16 | } 17 | 18 | export function find(iconName) { 19 | let iconPath = `${FileUtils.current_extension_path}/icons/${iconName}`; 20 | if (GLib.file_test(iconPath, GLib.FileTest.EXISTS)) { 21 | return Gio.icon_new_for_string(`${iconPath}`); 22 | } 23 | 24 | return Gio.ThemedIcon.new_from_names([iconName]); 25 | 26 | } 27 | 28 | export function findPath(iconName) { 29 | let iconPath = `${FileUtils.current_extension_path}/icons/${iconName}`; 30 | if (GLib.file_test(iconPath, GLib.FileTest.EXISTS)) { 31 | return iconPath; 32 | } 33 | 34 | return null; 35 | } 36 | -------------------------------------------------------------------------------- /utils/log.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import {PrefsUtils} from './prefsUtils.js'; 4 | 5 | 6 | export const Log = class { 7 | 8 | constructor() { 9 | } 10 | 11 | isDebug() { 12 | return PrefsUtils.isDebug(); 13 | } 14 | 15 | isVerboseLogging() { 16 | return PrefsUtils.isVerboseLogging(); 17 | } 18 | 19 | debug(logContent) { 20 | if (this.isDebug()) { 21 | log(`[DEBUG ][Another window session manager] ${logContent}`); 22 | } 23 | } 24 | 25 | error(e, logContent) { 26 | if (!(e instanceof Error)) { 27 | e = new Error(e); 28 | } 29 | logError(e, `[ERROR ][Another window session manager] ${logContent}`); 30 | } 31 | 32 | info(logContent) { 33 | if (this.isVerboseLogging()) { 34 | log(`[INFO ][Another window session manager] ${logContent}`); 35 | } 36 | } 37 | 38 | warn(logContent) { 39 | log(`[WARNING][Another window session manager] ${logContent}`); 40 | } 41 | 42 | destroy() { 43 | 44 | } 45 | 46 | // Return a singleton instance 47 | static getDefault() { 48 | if (!Log._default) { 49 | Log._default = new Log(); 50 | } 51 | return Log._default; 52 | } 53 | 54 | static destroyDefault() { 55 | if (Log._default) { 56 | Log._default.destroy(); 57 | delete Log._default; 58 | } 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /utils/metaWindowUtils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import Meta from 'gi://Meta'; 4 | 5 | 6 | /** 7 | * Get the stable window id, don't change even after gnome shell is restarted 8 | * 9 | * On X11, return xid; On Wayland, return id 10 | * 11 | * @returns stable window id 12 | */ 13 | export const getStableWindowId = function(metaWindow) { 14 | return Meta.is_wayland_compositor() ? metaWindow.get_id() : metaWindow.get_description(); 15 | } 16 | 17 | export const isSurfaceActor = function(clutterActor) { 18 | const className = clutterActor.constructor.$gtype.name; 19 | // Excepted MetaSurfaceActorX11 and MetaSurfaceActorWayland on X11 and Wayland, respectively 20 | return className.startsWith('MetaSurfaceActor'); 21 | } -------------------------------------------------------------------------------- /utils/prefsUtils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * The instance of the PrefsUtilsClass 5 | */ 6 | export let PrefsUtils = null; 7 | 8 | /** 9 | * Initialize the PrefsUtilsClass from extension.js or prefs.js so that it can be used. 10 | * 11 | * @param {*} extensionObject 12 | * @param {*} settings 13 | */ 14 | export function prefsUtilsInit(extensionObject, settings) { 15 | if (PrefsUtils) { 16 | return; 17 | } 18 | 19 | const prefsUtilsClass = new PrefsUtilsClass(); 20 | prefsUtilsClass._init(extensionObject, settings); 21 | PrefsUtils = prefsUtilsClass; 22 | } 23 | 24 | export function prefsUtilsDestroy() { 25 | if (PrefsUtils) { 26 | PrefsUtils.destroy(); 27 | PrefsUtils = null; 28 | } 29 | } 30 | 31 | /** 32 | * This class must be initialized using `prefsUtilsInit()` from extension.js or prefs.js before it can be used. 33 | */ 34 | const PrefsUtilsClass = class { 35 | 36 | constructor() { 37 | } 38 | 39 | _init(extensionObject, settings) { 40 | this.extensionObject = extensionObject; 41 | this.settings = settings; 42 | } 43 | 44 | getSettingString(settingName) { 45 | return this.settings.get_string(settingName); 46 | } 47 | 48 | getSettings() { 49 | return this.settings; 50 | } 51 | 52 | getExtensionPath() { 53 | return this.extensionObject.path; 54 | } 55 | 56 | isDebug() { 57 | return this.settings.get_boolean('debugging-mode'); 58 | } 59 | 60 | isVerboseLogging() { 61 | return this.settings.get_boolean('verbose-logging'); 62 | } 63 | 64 | destroy() { 65 | this.settings = null; 66 | this.extensionObject = null; 67 | } 68 | } 69 | 70 | -------------------------------------------------------------------------------- /utils/signal.js: -------------------------------------------------------------------------------- 1 | import GObject from 'gi://GObject'; 2 | 3 | 4 | export const Signal = class { 5 | 6 | constructor() { 7 | 8 | } 9 | 10 | /** 11 | * Disconnect signal from an object without the below error / warning in `journalctl`: 12 | * 13 | * ../gobject/gsignal.c:2732: instance '0x55629xxxxxx' has no handler with id '11000' 14 | */ 15 | disconnectSafely(obj, signalId) { 16 | if (!signalId) { 17 | return; 18 | } 19 | 20 | // https://gjs-docs.gnome.org/gobject20~2.66p/gobject.signal_handler_find 21 | // Fix ../gobject/gsignal.c:2732: instance '0x55629xxxxxx' has no handler with id '11000' in some case, see two callers for more info 22 | const matchedId = GObject.signal_handler_find( 23 | obj, // GObject.Object 24 | GObject.SignalMatchType.ID, 25 | signalId, 26 | null, null, null, null); 27 | if (matchedId) { 28 | obj.disconnect(signalId); 29 | } 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /utils/stringUtils.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * Format string like this and fill variables in ${} with real data: 5 | * 6 | * Name=${appName} Comment=${appName} Type=Application Exec=${commandLine} Icon=${icon} 7 | * 8 | */ 9 | export const format = function(stringTemplate, argumentsObj) { 10 | const obj = argumentsObj; 11 | if (typeof obj !== 'object') { 12 | throw(new Error('Wrong arguments, only supports object')); 13 | } 14 | 15 | for (const key in obj) { 16 | stringTemplate = stringTemplate.replaceAll("${" + key + "}", obj[key]); 17 | } 18 | return stringTemplate; 19 | } 20 | -------------------------------------------------------------------------------- /utils/subprocessUtils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import Gio from 'gi://Gio'; 4 | import GLib from 'gi://GLib'; 5 | 6 | import * as Log from './log.js'; 7 | 8 | 9 | export async function getProcessInfo(apps /*ShellApp*/, ignoreWindowsCb) { 10 | try { 11 | const pidSet = new Set(); 12 | for (const app of apps) { 13 | let metaWindows = app.get_windows(); 14 | for (const metaWindow of metaWindows) { 15 | if (ignoreWindowsCb && ignoreWindowsCb(metaWindow)) { 16 | continue; 17 | } 18 | 19 | const pid = metaWindow.get_pid(); 20 | // pid is `0` if not known 21 | // Note that pass `0` or negative value to `ps -p` will get `error: process ID out of range` 22 | if (pid > 0) pidSet.add(pid); 23 | } 24 | } 25 | 26 | if (!pidSet.size) return; 27 | 28 | // Separated with comma 29 | const pids = Array.from(pidSet).join(','); 30 | // TODO get_sandboxed_app_id() Gets an unique id for a sandboxed app (currently flatpaks and snaps are supported). 31 | const psCmd = ['ps', '--no-headers', '-p', `${pids}`, '-o', 'lstart,%cpu,%mem,pid,command']; 32 | 33 | return new Promise((resolve, reject) => { 34 | try { 35 | let proc = Gio.Subprocess.new( 36 | psCmd, 37 | Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE 38 | ); 39 | proc.communicate_utf8_async(null, null, (proc, res) => { 40 | try { 41 | const processInfoMap = new Map(); 42 | let [, stdout, stderr] = proc.communicate_utf8_finish(res); 43 | let status = proc.get_exit_status(); 44 | if (status === 0 && stdout) { 45 | const lines = stdout.trim(); 46 | for (const line of lines.split('\n')) { 47 | const processInfoArray = line.split(' ').filter(a => a); 48 | const pid = processInfoArray.slice(7, 8).join(); 49 | processInfoMap.set(Number(pid), processInfoArray); 50 | } 51 | return resolve(processInfoMap); 52 | } 53 | 54 | Log.Log.getDefault().error(new Error(`Failed to query process info. status: ${status}, stdout: ${stdout}, stderr: ${stderr}`)); 55 | resolve(processInfoMap); 56 | } catch(e) { 57 | Log.Log.getDefault().error(e); 58 | reject(e); 59 | } 60 | }); 61 | } catch (e) { 62 | Log.Log.getDefault().error(e); 63 | reject(e); 64 | } 65 | 66 | }) 67 | } catch (e) { 68 | Log.Log.getDefault().error(e); 69 | } 70 | } 71 | 72 | // A simple asynchronous read loop 73 | function readOutput(stream, lineBuffer) { 74 | stream.read_line_async(0, null, (stream, res) => { 75 | try { 76 | let line = stream.read_line_finish_utf8(res)[0]; 77 | 78 | if (line !== null) { 79 | lineBuffer.push(line); 80 | readOutput(stream, lineBuffer); 81 | } 82 | } catch (e) { 83 | logError(e); 84 | } 85 | }); 86 | } 87 | 88 | /** 89 | * We can get the pid after `proc.wait_finish(res)`, but note that the 90 | * subprocess might exit later with failure. 91 | * 92 | */ 93 | export const trySpawnCmdstr = function(commandLineString, callBackOnSuccess, callBackOnFailure) { 94 | let success_, argv; 95 | 96 | try { 97 | [success_, argv] = GLib.shell_parse_argv(commandLineString); 98 | } catch (err) { 99 | // Replace "Error invoking GLib.shell_parse_argv: " with 100 | // something nicer 101 | err.message = err.message.replace(/[^:]*: /, `${_('Could not parse command:')}\n`); 102 | throw err; 103 | } 104 | 105 | let proc = Gio.Subprocess.new( 106 | argv, 107 | Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE 108 | ); 109 | return new Promise((resolve, reject) => { 110 | proc.wait_async(null, (proc, res) => { 111 | try { 112 | let successful = proc.wait_finish(res); 113 | let status = proc.get_exit_status(); 114 | let stdoutInputStream = proc.get_stdout_pipe(); 115 | let stderrInputStream = proc.get_stderr_pipe(); 116 | if (!(stdoutInputStream instanceof Gio.DataInputStream)) { 117 | stdoutInputStream = new Gio.DataInputStream({ 118 | base_stream: stdoutInputStream, 119 | }); 120 | } 121 | 122 | if (!(stderrInputStream instanceof Gio.DataInputStream)) { 123 | stderrInputStream = new Gio.DataInputStream({ 124 | base_stream: stderrInputStream, 125 | }); 126 | } 127 | 128 | resolve([status === 0, status, stdoutInputStream, stderrInputStream]); 129 | } catch(e) { 130 | Log.Log.getDefault().error(e); 131 | reject(e); 132 | } 133 | }); 134 | }); 135 | } 136 | 137 | /** 138 | * Deprecated. Use `trySpawnCmdstr()` instead. 139 | * 140 | * Since `proc.communicate_utf8_finish(res)` only returns value 141 | * after the subprocess (created by `commandLineString`) 142 | * exits, we cannot get the pid right after the subprocess launches. 143 | * So there will be some kind of blocking here. 144 | */ 145 | export const trySpawnCmdstrWithBlocking = function(commandLineString, callBackOnSuccess, callBackOnFailure) { 146 | let success_, argv; 147 | 148 | try { 149 | [success_, argv] = GLib.shell_parse_argv(commandLineString); 150 | } catch (err) { 151 | // Replace "Error invoking GLib.shell_parse_argv: " with 152 | // something nicer 153 | err.message = err.message.replace(/[^:]*: /, `${_('Could not parse command:')}\n`); 154 | throw err; 155 | } 156 | 157 | let proc = Gio.Subprocess.new( 158 | argv, 159 | Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE 160 | ); 161 | return new Promise((resolve, reject) => { 162 | proc.communicate_utf8_async(null, null, (proc, res) => { 163 | try { 164 | let [, stdout, stderr] = proc.communicate_utf8_finish(res); 165 | let status = proc.get_exit_status(); 166 | resolve([status === 0, status, stdout, stderr]); 167 | } catch(e) { 168 | Log.Log.getDefault().error(e); 169 | reject(e); 170 | } 171 | }); 172 | }); 173 | } 174 | 175 | export const trySpawn = async function(commandLineArray, callBackOnSuccess, callBackOnFailure) { 176 | try { 177 | return await new Promise((resolve, reject) => { 178 | trySpawnAsync(commandLineArray, 179 | (output) => { 180 | if (callBackOnSuccess) { 181 | callBackOnSuccess(output); 182 | } 183 | resolve(output); 184 | }, 185 | (output, status) => { 186 | if (callBackOnFailure) { 187 | callBackOnFailure(output, status); 188 | } 189 | reject(new Error(output)); 190 | }); 191 | }); 192 | } catch (e) { 193 | Log.Log.getDefault().error(e); 194 | } 195 | } 196 | /** 197 | * Based on: 198 | * 1. https://gjs.guide/guides/gio/subprocesses.html#asynchronous-communication 199 | * 2. https://gitlab.gnome.org/GNOME/gnome-shell/blob/8fda3116f03d95fabf3fac6d082b5fa268158d00/js/misc/util.js:L111 200 | * 201 | * This implement will return the `stderr` and `stdout` to caller via two callback 202 | * `callBackOnFailure` and `callBackOnFailure` 203 | * 204 | */ 205 | export const trySpawnAsync = function(commandLineArray, callBackOnSuccess, callBackOnFailure) { 206 | try { 207 | let [, pid, stdin, stdout, stderr] = GLib.spawn_async_with_pipes( 208 | // Working directory, passing %null to use the parent's 209 | null, 210 | // An array of arguments 211 | commandLineArray, 212 | // Process ENV, passing %null to use the parent's 213 | null, 214 | // Flags; we need to use PATH so `ls` can be found and also need to know 215 | // when the process has finished to check the output and status. 216 | GLib.SpawnFlags.SEARCH_PATH | GLib.SpawnFlags.DO_NOT_REAP_CHILD, 217 | // Child setup function 218 | () => { 219 | try { 220 | global.context.restore_rlimit_nofile(); 221 | } catch (err) { 222 | } 223 | } 224 | ); 225 | 226 | // Any unsused streams still have to be closed explicitly, otherwise the 227 | // file descriptors may be left open 228 | GLib.close(stdin); 229 | 230 | // Okay, now let's get output stream for `stdout` 231 | let stdoutStream = new Gio.DataInputStream({ 232 | base_stream: new Gio.UnixInputStream({ 233 | fd: stdout, 234 | close_fd: true 235 | }), 236 | close_base_stream: true 237 | }); 238 | 239 | // We'll read the output asynchronously to avoid blocking the main thread 240 | let stdoutLines = []; 241 | readOutput(stdoutStream, stdoutLines); 242 | 243 | // We want the real error from `stderr`, so we'll have to do the same here 244 | let stderrStream = new Gio.DataInputStream({ 245 | base_stream: new Gio.UnixInputStream({ 246 | fd: stderr, 247 | close_fd: true 248 | }), 249 | close_base_stream: true 250 | }); 251 | 252 | let stderrLines = []; 253 | readOutput(stderrStream, stderrLines); 254 | 255 | // Watch for the process to finish, being sure to set a lower priority than 256 | // we set for the read loop, so we get all the output 257 | GLib.child_watch_add(GLib.PRIORITY_DEFAULT_IDLE, pid, (pid, status) => { 258 | // TODO Note that this status is usually not equal to the integer passed to `exit()` 259 | // See: https://gitlab.gnome.org/GNOME/glib/-/blob/5d498f4d1ce0fd124cbfb065fb2155a2e964bf5f/glib/gmain.h#L244 260 | if (status === 0) { 261 | if (callBackOnSuccess) { 262 | callBackOnSuccess(stdoutLines.join('\n')); 263 | } 264 | } else { 265 | if (callBackOnFailure) { 266 | callBackOnFailure(stderrLines.join('\n')); 267 | } 268 | } 269 | 270 | // Ensure we close the remaining streams and process 271 | stdoutStream.close(null); 272 | stderrStream.close(null); 273 | GLib.spawn_close_pid(pid); 274 | 275 | }); 276 | } catch (e) { 277 | logError(e); 278 | } 279 | } 280 | 281 | 282 | /** 283 | * Execute a command asynchronously and check the exit status. 284 | * 285 | * If given, @cancellable can be used to stop the process before it finishes. 286 | * 287 | * @param {string[]} argv - a list of string arguments 288 | * @param {Gio.Cancellable} [cancellable] - optional cancellable object 289 | * @returns {Promise} - The process success 290 | */ 291 | export async function execCheck(argv, cancellable = null) { 292 | let cancelId = 0; 293 | let proc = new Gio.Subprocess({ 294 | argv: argv, 295 | flags: Gio.SubprocessFlags.NONE 296 | }); 297 | proc.init(cancellable); 298 | 299 | if (cancellable instanceof Gio.Cancellable) { 300 | cancelId = cancellable.connect(() => proc.force_exit()); 301 | } 302 | 303 | return new Promise((resolve, reject) => { 304 | proc.wait_check_async(null, (proc, res) => { 305 | try { 306 | if (!proc.wait_check_finish(res)) { 307 | let status = proc.get_exit_status(); 308 | 309 | throw new Gio.IOErrorEnum({ 310 | code: Gio.io_error_from_errno(status), 311 | message: GLib.strerror(status) 312 | }); 313 | } 314 | 315 | resolve(); 316 | } catch (e) { 317 | reject(e); 318 | } finally { 319 | if (cancelId > 0) { 320 | cancellable.disconnect(cancelId); 321 | } 322 | } 323 | }); 324 | }); 325 | } 326 | 327 | -------------------------------------------------------------------------------- /utils/tooltip.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import Clutter from 'gi://Clutter'; 4 | import Gio from 'gi://Gio'; 5 | import GLib from 'gi://GLib'; 6 | import Pango from 'gi://Pango'; 7 | import St from 'gi://St'; 8 | 9 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 10 | 11 | /** 12 | * Note: Adapted from: https://github.com/GSConnect/gnome-shell-extension-gsconnect/blob/master/src/shell/tooltip.js 13 | * 14 | * I may or may not modify it to fit the needs of this project. 15 | */ 16 | 17 | /** 18 | * An StTooltip for ClutterActors 19 | * 20 | * Adapted from: https://github.com/RaphaelRochet/applications-overview-tooltip 21 | * See also: https://github.com/GNOME/gtk/blob/master/gtk/gtktooltip.c 22 | */ 23 | var TOOLTIP_BROWSE_ID = 0; 24 | var TOOLTIP_BROWSE_MODE = false; 25 | 26 | export const Tooltip = class Tooltip { 27 | 28 | constructor(params) { 29 | Object.assign(this, params); 30 | 31 | this._bin = null; 32 | this._hoverTimeoutId = 0; 33 | this._showing = false; 34 | 35 | this._destroyId = this.parent.connect( 36 | 'destroy', 37 | this.destroy.bind(this) 38 | ); 39 | 40 | this._hoverId = this.parent.connect( 41 | 'notify::hover', 42 | this._onHover.bind(this) 43 | ); 44 | 45 | this._buttonPressEventId = this.parent.connect( 46 | 'button-press-event', 47 | this._hide.bind(this) 48 | ); 49 | } 50 | 51 | get custom() { 52 | if (this._custom === undefined) 53 | this._custom = null; 54 | 55 | return this._custom; 56 | } 57 | 58 | set custom(actor) { 59 | this._custom = actor; 60 | this._markup = null; 61 | this._text = null; 62 | 63 | if (this._showing) 64 | this._show(); 65 | } 66 | 67 | get gicon() { 68 | if (this._gicon === undefined) 69 | this._gicon = null; 70 | 71 | return this._gicon; 72 | } 73 | 74 | set gicon(gicon) { 75 | this._gicon = gicon; 76 | 77 | if (this._showing) 78 | this._show(); 79 | } 80 | 81 | get icon() { 82 | return (this.gicon) ? this.gicon.name : null; 83 | } 84 | 85 | set icon(icon_name) { 86 | if (!icon_name) 87 | this.gicon = null; 88 | else 89 | this.gicon = new Gio.ThemedIcon({name: icon_name}); 90 | } 91 | 92 | get markup() { 93 | if (this._markup === undefined) 94 | this._markup = null; 95 | 96 | return this._markup; 97 | } 98 | 99 | set markup(text) { 100 | this._markup = text; 101 | this._text = null; 102 | 103 | if (this._showing) 104 | this._show(); 105 | } 106 | 107 | get text() { 108 | if (this._text === undefined) 109 | this._text = null; 110 | 111 | return this._text; 112 | } 113 | 114 | set text(text) { 115 | this._markup = null; 116 | this._text = text; 117 | 118 | if (this._showing) 119 | this._show(); 120 | } 121 | 122 | get x_offset() { 123 | if (this._x_offset === undefined) 124 | this._x_offset = 0; 125 | 126 | return this._x_offset; 127 | } 128 | 129 | set x_offset(offset) { 130 | this._x_offset = (Number.isInteger(offset)) ? offset : 0; 131 | } 132 | 133 | get y_offset() { 134 | if (this._y_offset === undefined) 135 | this._y_offset = 0; 136 | 137 | return this._y_offset; 138 | } 139 | 140 | set y_offset(offset) { 141 | this._y_offset = (Number.isInteger(offset)) ? offset : 0; 142 | } 143 | 144 | _show() { 145 | if (this.text === null && this.markup === null) 146 | return this._hide(); 147 | 148 | if (this._bin === null) { 149 | this._bin = new St.Bin({ 150 | style_class: 'osd-window awsm-tooltip', 151 | opacity: 232, 152 | }); 153 | 154 | if (this.custom) { 155 | this._bin.child = this.custom; 156 | } else { 157 | this._bin.child = new St.BoxLayout({vertical: false}); 158 | 159 | if (this.gicon) { 160 | this._bin.child.icon = new St.Icon({ 161 | gicon: this.gicon, 162 | y_align: St.Align.START, 163 | }); 164 | this._bin.child.icon.set_y_align(Clutter.ActorAlign.START); 165 | this._bin.child.add_child(this._bin.child.icon); 166 | } 167 | 168 | this.label = new St.Label({text: this.markup || this.text}); 169 | this.label.clutter_text.line_wrap = true; 170 | this.label.clutter_text.line_wrap_mode = Pango.WrapMode.WORD; 171 | this.label.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; 172 | this.label.clutter_text.use_markup = (this.markup); 173 | this._bin.child.add_child(this.label); 174 | } 175 | 176 | Main.layoutManager.uiGroup.add_child(this._bin); 177 | Main.layoutManager.uiGroup.set_child_above_sibling(this._bin, null); 178 | } else if (this.custom) { 179 | this._bin.child = this.custom; 180 | } else { 181 | if (this._bin.child.icon) 182 | this._bin.child.icon.destroy(); 183 | 184 | if (this.gicon) { 185 | this._bin.child.icon = new St.Icon({gicon: this.gicon}); 186 | this._bin.child.insert_child_at_index(this._bin.child.icon, 0); 187 | } 188 | 189 | this.label.clutter_text.text = this.markup || this.text; 190 | this.label.clutter_text.use_markup = (this.markup); 191 | } 192 | 193 | // Position tooltip 194 | let [x, y] = this.parent.get_transformed_position(); 195 | x = (x + (this.parent.width / 2)) - Math.round(this._bin.width / 2); 196 | 197 | x += this.x_offset; 198 | y += this.y_offset; 199 | 200 | // Show tooltip 201 | if (this._showing) { 202 | this._bin.ease({ 203 | x: x, 204 | y: y, 205 | time: 0.15, 206 | transition: Clutter.AnimationMode.EASE_OUT_QUAD, 207 | }); 208 | } else { 209 | this._bin.set_position(x, y); 210 | this._bin.ease({ 211 | opacity: 232, 212 | time: 0.15, 213 | transition: Clutter.AnimationMode.EASE_OUT_QUAD, 214 | }); 215 | 216 | this._showing = true; 217 | } 218 | 219 | // Enable browse mode 220 | TOOLTIP_BROWSE_MODE = true; 221 | 222 | if (TOOLTIP_BROWSE_ID) { 223 | GLib.source_remove(TOOLTIP_BROWSE_ID); 224 | TOOLTIP_BROWSE_ID = 0; 225 | } 226 | 227 | if (this._hoverTimeoutId) { 228 | GLib.source_remove(this._hoverTimeoutId); 229 | this._hoverTimeoutId = 0; 230 | } 231 | } 232 | 233 | _hide() { 234 | if (this._bin) { 235 | this._bin.ease({ 236 | opacity: 0, 237 | time: 0.10, 238 | transition: Clutter.AnimationMode.EASE_OUT_QUAD, 239 | onComplete: () => { 240 | Main.layoutManager.uiGroup.remove_child(this._bin); 241 | 242 | if (this.custom) 243 | this._bin.remove_child(this.custom); 244 | 245 | this._bin.destroy(); 246 | this._bin = null; 247 | }, 248 | }); 249 | } 250 | 251 | TOOLTIP_BROWSE_ID = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, () => { 252 | TOOLTIP_BROWSE_MODE = false; 253 | TOOLTIP_BROWSE_ID = 0; 254 | return false; 255 | }); 256 | 257 | if (this._hoverTimeoutId) { 258 | GLib.source_remove(this._hoverTimeoutId); 259 | this._hoverTimeoutId = 0; 260 | } 261 | 262 | this._showing = false; 263 | this._hoverTimeoutId = 0; 264 | } 265 | 266 | _onHover() { 267 | if (this.parent.hover) { 268 | if (!this._hoverTimeoutId) { 269 | if (this._showing) { 270 | this._show(); 271 | } else { 272 | this._hoverTimeoutId = GLib.timeout_add( 273 | GLib.PRIORITY_DEFAULT, 274 | (TOOLTIP_BROWSE_MODE) ? 60 : 500, 275 | () => { 276 | this._show(); 277 | this._hoverTimeoutId = 0; 278 | return false; 279 | } 280 | ); 281 | } 282 | } 283 | } else { 284 | this._hide(); 285 | } 286 | } 287 | 288 | destroy() { 289 | this.parent.disconnect(this._destroyId); 290 | this.parent.disconnect(this._hoverId); 291 | this.parent.disconnect(this._buttonPressEventId); 292 | 293 | if (this.custom) 294 | this.custom.destroy(); 295 | 296 | if (this._bin) { 297 | Main.layoutManager.uiGroup.remove_child(this._bin); 298 | this._bin.destroy(); 299 | } 300 | 301 | if (TOOLTIP_BROWSE_ID) { 302 | GLib.source_remove(TOOLTIP_BROWSE_ID); 303 | TOOLTIP_BROWSE_ID = 0; 304 | } 305 | 306 | if (this._hoverTimeoutId) { 307 | GLib.source_remove(this._hoverTimeoutId); 308 | this._hoverTimeoutId = 0; 309 | } 310 | } 311 | }; 312 | 313 | -------------------------------------------------------------------------------- /windowTilingSupport.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import Shell from 'gi://Shell'; 4 | import Meta from 'gi://Meta'; 5 | import GObject from 'gi://GObject'; 6 | 7 | import * as Log from './utils/log.js'; 8 | import {PrefsUtils} from './utils/prefsUtils.js'; 9 | 10 | 11 | // Singleton class, all methods are `static` 12 | export class WindowTilingSupport { 13 | 14 | static initialize() { 15 | this._log = new Log.Log(); 16 | this._settings = PrefsUtils.getSettings(); 17 | this._defaultAppSystem = Shell.AppSystem.get_default(); 18 | 19 | this._signals = new WindowTilingSupportSignals(); 20 | 21 | // Used for getting another raised signal id to prevent 'too much recursion' due to raising each other. 22 | this._signalsConnectedMap = new Map(); 23 | 24 | this._grabbedWindowsAboutToUntileMap = new Map(); 25 | 26 | this._grabOpBeginId = global.display.connect('grab-op-begin', this._grabOpBegin.bind(this)); 27 | this._grabOpEndId = global.display.connect('grab-op-end', this._grabOpEnd.bind(this)); 28 | 29 | } 30 | 31 | static prepareToTile(metaWindow, window_tiling) { 32 | if (!window_tiling) return; 33 | if (!this._settings.get_boolean('restore-window-tiling')) return; 34 | const windowAboutToResize = this._getWindowAboutToResize(window_tiling); 35 | if (!windowAboutToResize) return; 36 | 37 | metaWindow._tile_match_awsm = windowAboutToResize; 38 | windowAboutToResize._tile_match_awsm = metaWindow; 39 | 40 | this._signals.emit('window-tiled', metaWindow, windowAboutToResize); 41 | 42 | // Connect `raised` only once and this will prevent `JS ERROR: too much recursion` 43 | if (!this._signalsConnectedMap.get(metaWindow)) { 44 | const raisedId = metaWindow.connect('raised', () => { 45 | const raisedTogether = this._settings.get_boolean('raise-windows-together'); 46 | if (raisedTogether) { 47 | const anotherWindowRaisedId = this._signalsConnectedMap.get(windowAboutToResize); 48 | windowAboutToResize.block_signal_handler(anotherWindowRaisedId); 49 | windowAboutToResize.raise(); 50 | windowAboutToResize.unblock_signal_handler(anotherWindowRaisedId); 51 | } 52 | }); 53 | this._signalsConnectedMap.set(metaWindow, raisedId); 54 | } 55 | } 56 | 57 | static _grabOpBegin(display, grabbedWindow, grabOp) { 58 | // Fix `JS ERROR: TypeError: grabbedWindow is null` while `grab-op-begin` by `dash to panel`, 59 | // who emits nullish grabbedWindow. 60 | if (!grabbedWindow) return; 61 | 62 | // Check if the grabbed window has been in a tiling state with another window 63 | const windowAboutToResize = grabbedWindow._tile_match_awsm; 64 | if (!windowAboutToResize || windowAboutToResize._tile_match_awsm !== grabbedWindow) 65 | return; 66 | 67 | // When position changed 68 | if (grabOp === Meta.GrabOp.MOVING) { 69 | const oldGrabbedWindowRect = grabbedWindow.get_frame_rect().copy(); 70 | this._grabbedWindowsAboutToUntileMap.set(grabbedWindow, oldGrabbedWindowRect); 71 | return; 72 | } 73 | 74 | if (!this._settings.get_boolean('restore-window-tiling')) return; 75 | 76 | this._sizeChangedId = grabbedWindow.connect('size-changed', () => { 77 | const grabbedWindowRect = grabbedWindow.get_frame_rect(); 78 | const windowAboutToResizeRect = windowAboutToResize.get_frame_rect(); 79 | const grabbedWindowOnLeftSide = grabbedWindowRect.x < windowAboutToResizeRect.x; 80 | let xywh = null; 81 | if (grabbedWindowOnLeftSide) { 82 | xywh = [ 83 | grabbedWindowRect.width, 84 | windowAboutToResizeRect.y, 85 | windowAboutToResizeRect.width - (grabbedWindowRect.width - windowAboutToResizeRect.x), 86 | windowAboutToResizeRect.height]; 87 | } else { 88 | xywh = [ 89 | windowAboutToResizeRect.x, 90 | windowAboutToResizeRect.y, 91 | grabbedWindowRect.x, 92 | windowAboutToResizeRect.height]; 93 | } 94 | 95 | if (xywh) { 96 | windowAboutToResize.move_resize_frame(false, ...xywh); 97 | } 98 | 99 | }); 100 | } 101 | 102 | static _grabOpEnd(display, grabbedWindow, grabOp) { 103 | // grabbedWindow is null, tested on Fedora 35 with Gnome 41.6 and Wayland, 104 | // by clicking the indicator show and then hide the popup menu 105 | if (!grabbedWindow) return; 106 | 107 | const oldGrabbedWindowRect = this._grabbedWindowsAboutToUntileMap.get(grabbedWindow); 108 | const currentRect = grabbedWindow.get_frame_rect(); 109 | // Untile if any of x, y, width and height changed 110 | if (oldGrabbedWindowRect && 111 | (oldGrabbedWindowRect.x !== currentRect.x 112 | || oldGrabbedWindowRect.y !== currentRect.y 113 | || oldGrabbedWindowRect.width !== currentRect.width 114 | || oldGrabbedWindowRect.height !== currentRect.height)) 115 | { 116 | const anotherTilingWindow = grabbedWindow._tile_match_awsm; 117 | 118 | this._log.debug(`Untiling ${grabbedWindow.get_title()}`); 119 | delete grabbedWindow._tile_match_awsm; 120 | 121 | if (anotherTilingWindow) { 122 | this._log.debug(`Untiling ${anotherTilingWindow.get_title()}`); 123 | delete anotherTilingWindow._tile_match_awsm; 124 | } 125 | this._grabbedWindowsAboutToUntileMap.delete(grabbedWindow); 126 | 127 | this._disconnectRaisedSignals(); 128 | 129 | this._signals.emit('window-untiled', grabbedWindow, anotherTilingWindow); 130 | } 131 | 132 | if (this._sizeChangedId) { 133 | grabbedWindow.disconnect(this._sizeChangedId); 134 | this._sizeChangedId = 0; 135 | } 136 | 137 | } 138 | 139 | static _getWindowAboutToResize(window_tiling) { 140 | if (!window_tiling) return null; 141 | const window_tile_for = window_tiling.window_tile_for; 142 | const shellApp = this._defaultAppSystem.lookup_app(window_tile_for.desktop_file_id); 143 | if (!shellApp) return null; 144 | const windows = shellApp.get_windows(); 145 | if (!windows || !windows.length) return null; 146 | 147 | let windowAboutToResize = null; 148 | if (windows.length === 1) { 149 | windowAboutToResize = windows[0]; 150 | } else { 151 | // Get one window by matching title 152 | for (const win of windows) { 153 | if (win.get_title() === window_tile_for.window_title) { 154 | windowAboutToResize = win; 155 | break; 156 | } 157 | } 158 | } 159 | 160 | return windowAboutToResize; 161 | } 162 | 163 | static connect(signal, func) { 164 | this._signals.connect(signal, func); 165 | } 166 | 167 | static disconnect(id) { 168 | this._signals.disconnect(id); 169 | } 170 | 171 | static _disconnectRaisedSignals() { 172 | if (this._signalsConnectedMap) { 173 | this._signalsConnectedMap.forEach((id, obj) => { 174 | obj.disconnect(id); 175 | }); 176 | this._signalsConnectedMap.clear(); 177 | } 178 | } 179 | 180 | static destroy() { 181 | 182 | if (this._grabbedWindowsAboutToUntileMap) { 183 | this._grabbedWindowsAboutToUntileMap.clear(); 184 | this._grabbedWindowsAboutToUntileMap = null; 185 | } 186 | 187 | this._disconnectRaisedSignals(); 188 | 189 | this._signalsConnectedMap = null; 190 | 191 | if (this._grabOpBeginId) { 192 | global.display.disconnect(this._grabOpBeginId); 193 | this._grabOpBeginId = 0; 194 | } 195 | 196 | if (this._grabOpEndId) { 197 | global.display.disconnect(this._grabOpEndId); 198 | this._grabOpEndId = 0; 199 | } 200 | } 201 | 202 | 203 | } 204 | 205 | const WindowTilingSupportSignals = GObject.registerClass({ 206 | Signals: { 207 | 'window-tiled': { 208 | param_types: [Meta.Window.$gtype, Meta.Window.$gtype], 209 | flags: GObject.SignalFlags.RUN_LAST, 210 | }, 211 | 'window-untiled': { 212 | param_types: [Meta.Window.$gtype, Meta.Window.$gtype], 213 | flags: GObject.SignalFlags.RUN_LAST, 214 | }, 215 | } 216 | }, class WindowTilingSupportSignals extends GObject.Object{ 217 | 218 | _init() { 219 | super._init(); 220 | } 221 | 222 | 223 | }); --------------------------------------------------------------------------------