├── .eslintrc.yml ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ ├── build.yml │ └── release.yaml ├── .gitignore ├── .prettierrc.yaml ├── .reuse └── dep5 ├── LICENSES ├── CC-BY-SA-4.0.txt ├── GPL-2.0-or-later.txt ├── GPL-3.0-or-later.txt ├── LGPL-2.0-or-later.txt └── MIT.txt ├── Makefile ├── README.md ├── build ├── default.mk └── gnome-extension.mk ├── data └── img │ ├── externalScheduleFeature.png │ ├── icon.svg │ ├── screenshot.png │ ├── shutdown-timer-path.svg │ ├── shutdown-timer-symbolic-full.svg │ └── wakeInfoFeature.png ├── lint ├── eslintrc-extension.yml ├── eslintrc-gjs.yml └── eslintrc-shell.yml ├── package.json ├── package.json.license ├── po ├── cs.po ├── de.po ├── el.po ├── es.po ├── fr.po ├── it.po ├── main.pot ├── nl.po ├── pl.po ├── pt.po ├── ru.po ├── sk.po ├── tr.po └── zh_CN.po ├── scripts ├── .gitignore └── ranking.py ├── src ├── dbus-interfaces │ ├── org.freedesktop.login1.Manager.xml │ ├── org.gnome.SessionManager.xml │ └── org.gnome.Shell.Extensions.ShutdownTimer.xml ├── dbus-service │ ├── action.js │ ├── control.js │ ├── shutdown-timer-dbus.js │ └── timer.js ├── extension.js ├── icons │ ├── shutdown-timer-symbolic.svg │ └── shutdown-timer-symbolic.svg.license ├── metadata.json ├── metadata.json.license ├── modules │ ├── info-fetcher.js │ ├── injection.js │ ├── install.js │ ├── menu-item.js │ ├── quicksettings.js │ ├── schedule-info.js │ ├── session-mode-aware.js │ ├── text-box.js │ ├── translation.js │ └── util.js ├── polkit │ ├── 10-dem.shutdowntimer.settimers.rules │ ├── 10-dem.shutdowntimer.settimers.rules.legacy │ └── dem.shutdowntimer.policy.in ├── prefs.js ├── schemas │ └── org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml ├── stylesheet.css ├── tool │ ├── installer.sh │ └── shutdowntimerctl └── ui │ └── prefs.ui └── tests ├── injection.test.js ├── test-base.js └── test.sh /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 Romain Vigier 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | extends: 5 | - ./lint/eslintrc-gjs.yml 6 | - ./lint/eslintrc-shell.yml 7 | - ./lint/eslintrc-extension.yml 8 | - prettier 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | _A clear and concise description of what the bug is._ 12 | - If applicable, show log appropriate output `journalctl -o short-iso /usr/bin/gnome-shell` 13 | - Does the bug only occur with an additional gnome shell extension enabled or with only the "Shutdown Timer" extension enabled? 14 | 15 | **To Reproduce** 16 | _Steps to reproduce the behavior:_ 17 | 1. _Go to '...'_ 18 | 2. _Select '....'_ 19 | 3. _Click on ' ....'_ 20 | 4. _See error_ 21 | 22 | **Expected behavior** 23 | _A clear and concise description of what you expected to happen._ 24 | 25 | **Screenshots** 26 | _If applicable, add screenshots to help explain your problem._ 27 | 28 | **System** 29 | - Extension version: `gnome-extensions info ShutdownTimer@deminder | grep -i version` 30 | - GNOME shell version: `gnome-shell --version` 31 | - Distro: _Ubuntu 22.04, Fedora 37, etc._ 32 | 33 | 34 | **Additional context** 35 | _Add any other context about the problem here._ 36 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | # Triggers the workflow on push or pull request events but only for the master branch or ego-compatible 5 | push: 6 | branches: [ master, ego-compatible ] 7 | pull_request: 8 | branches: [ master, ego-compatible ] 9 | 10 | # Allows you to run this workflow manually from the Actions tab 11 | workflow_dispatch: 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v2 18 | - uses: actions/setup-node@v2.4.0 19 | - name: Setup build dependencies 20 | run: | 21 | npm install 22 | sudo apt-get update 23 | sudo apt-get install -y reuse gnome-shell-extensions gettext 24 | 25 | - name: Run linter 26 | run: make lint 27 | 28 | - name: Build debug and default zip 29 | run: make all 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - 'v*' 5 | 6 | name: Upload Extension Zip as Release Asset 7 | 8 | jobs: 9 | build: 10 | name: Upload Release Asset 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v2 15 | - name: Build project 16 | run: | 17 | make zip 18 | - name: Create Release 19 | id: create_release 20 | uses: actions/create-release@v1 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | with: 24 | tag_name: ${{ github.ref }} 25 | release_name: Release version ${{ github.ref }} 26 | draft: true 27 | prerelease: false 28 | - name: Upload Extension Zip as Release Asset 29 | id: upload-release-asset 30 | uses: actions/upload-release-asset@v1 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | with: 34 | upload_url: ${{ steps.create_release.outputs.upload_url }} 35 | asset_path: ./target/default/ShutdownTimer@deminder.shell-extension.zip 36 | asset_name: ShutdownTimer@deminder.shell-extension.zip 37 | asset_content_type: application/zip 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Deminder 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | /src/locale 5 | /src/schemas/gschemas.compiled 6 | target/ 7 | *~ 8 | *# 9 | *-gtk4.ui 10 | *.zip 11 | *.png 12 | *.mo 13 | debug-guest.sh 14 | guest-ssh.env 15 | 16 | node_modules 17 | package-lock.json 18 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Deminder 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | arrowParens: avoid 5 | singleQuote: true 6 | -------------------------------------------------------------------------------- /.reuse/dep5: -------------------------------------------------------------------------------- 1 | Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Upstream-Name: battery-indicator-icon 3 | Upstream-Contact: Deminder 4 | Source: https://github.com/Deminder/battery-indicator-icon 5 | 6 | Files: po/*.po po/main.pot 7 | Copyright: Translators 8 | License: CC-BY-SA-4.0 9 | 10 | Files: data/** 11 | Copyright: 2023 Deminder 12 | License: CC-BY-SA-4.0 13 | 14 | Files: src/polkit/* 15 | Copyright: 2023 Deminder 16 | License: GPL-3.0-or-later 17 | 18 | Files: src/ui/* 19 | Copyright: 2023 Deminder 20 | License: GPL-3.0-or-later 21 | 22 | Files: .github/** 23 | Copyright: 2023 Deminder 24 | License: MIT 25 | -------------------------------------------------------------------------------- /LICENSES/GPL-2.0-or-later.txt: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc. 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA 6 | 7 | Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too. 12 | 13 | When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. 14 | 15 | To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. 16 | 17 | For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. 18 | 19 | We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. 20 | 21 | Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. 22 | 23 | Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. 24 | 25 | The precise terms and conditions for copying, distribution and modification follow. 26 | 27 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 28 | 29 | 0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". 30 | 31 | Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. 32 | 33 | 1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. 34 | 35 | You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 36 | 37 | 2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: 38 | 39 | a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. 40 | 41 | b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License. 42 | 43 | c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) 44 | 45 | These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. 46 | 47 | Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. 48 | 49 | In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 50 | 51 | 3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following: 52 | 53 | a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, 54 | 55 | b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, 56 | 57 | c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) 58 | 59 | The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. 60 | 61 | If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. 62 | 63 | 4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 64 | 65 | 5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. 66 | 67 | 6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. 68 | 69 | 7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. 70 | 71 | If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. 72 | 73 | It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. 74 | 75 | This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 76 | 77 | 8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 78 | 79 | 9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. 80 | 81 | Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. 82 | 83 | 10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. 84 | 85 | NO WARRANTY 86 | 87 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 88 | 89 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 90 | 91 | END OF TERMS AND CONDITIONS 92 | 93 | How to Apply These Terms to Your New Programs 94 | 95 | If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. 96 | 97 | To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. 98 | 99 | one line to give the program's name and an idea of what it does. Copyright (C) yyyy name of author 100 | 101 | This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. 102 | 103 | This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. 104 | 105 | You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. Also add information on how to contact you by electronic and paper mail. 106 | 107 | If the program is interactive, make it output a short notice like this when it starts in an interactive mode: 108 | 109 | Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. 110 | 111 | The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program. 112 | 113 | You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names: 114 | 115 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' (which makes passes at compilers) written by James Hacker. 116 | 117 | signature of Ty Coon, 1 April 1989 Ty Coon, President of Vice 118 | -------------------------------------------------------------------------------- /LICENSES/MIT.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Deminder 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | include build/default.mk 5 | 6 | TRANSLATION_MODULE := $(SRC_DIR)/modules/translation.js 7 | 8 | include build/gnome-extension.mk 9 | 10 | test: 11 | @./tests/test.sh 12 | 13 | .PHONY: test 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 5 | 6 |

Shutdown Timer for Gnome Shell

7 |

8 | Shutdown Timer Icon 9 | 10 | Get it on GNOME Extensions 11 | 12 | CI 13 |
14 | Shutdown/reboot/suspend your device after a specific time or wake with a rtc alarm. 15 |

16 | 17 | ![Screenshot](data/img//screenshot.png) 18 | 19 | ## Features 20 | - Timer for _Poweroff_, _Reboot_, _Suspend_ 21 | - Show system wake and shutdown schedules 22 | ![externalScheduleMenu](data/img/externalScheduleFeature.png) 23 | - Unlock-dialog does *not* interrupt the timer 24 | - Control `rtcwake` and `shutdown` as user by installing a privileged control script `shutdowntimerctl` from the extension settings window 25 | - Option for ensuring system shutdown with additional `shutdown ${REQUESTED_MINUTES + 1}` (for _Poweroff_ and _Reboot_). *Note*: non-root users will be blocked from logging in 26 | 27 | ## Manual Installation 28 | 29 | Requires `gnome-shell-extensions` and `gettext`: 30 | 31 | ```(shell) 32 | make install 33 | ``` 34 | 35 | OR automatically switch to the last supported release version before install `make supported-install`. 36 | 37 | ### Tool installation 38 | 39 | Manually install privileged script for rtcwake and shutdown with: 40 | 41 | ```(shell) 42 | sudo ./src/tool/installer.sh --tool-user $USER install 43 | ``` 44 | 45 | ## Development 46 | 47 | ### Debug 48 | 49 | Install via `$GUEST_SSHCMD` on a virtual/remote host `$GUEST_SSHADDR` for debugging: 50 | 51 | ```(shell) 52 | GUEST_SSHCMD=ssh GUEST_SSHADDR=guest@vm make debug-guest 53 | ``` 54 | 55 | Install locally with debug output enabled: 56 | 57 | ```(shell) 58 | make debug-install 59 | ``` 60 | 61 | ### Update Translations 62 | 63 | Extract transalable text from sources to template file `po/main.pot` and update `.po` files: 64 | 65 | ```(shell) 66 | make translations 67 | ``` 68 | 69 | ### References 70 | 71 | - https://gjs.guide/extensions/ 72 | - https://gjs.guide/guides/ 73 | - https://gjs-docs.gnome.org/ 74 | - [D-Bus and Polkit (Introduction)](https://venam.nixers.net/blog/unix/2020/07/06/dbus-polkit.html) 75 | - Forked (June 2021) [neumann-d/ShutdownTimer](https://github.com/neumann-d/ShutdownTimer) 76 | -------------------------------------------------------------------------------- /build/default.mk: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Deminder 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | PO_DIR ?= po 5 | PO_FILES ?= $(wildcard $(PO_DIR)/*.po) 6 | POT_MAIN ?= $(PO_DIR)/main.pot 7 | SRC_DIR ?= src 8 | SOURCE_FILES ?= $(filter-out %.mo %.compiled,$(shell find $(SRC_DIR) -type f)) 9 | TRANSLATABLE_FILES ?= $(filter %.js %.ui %.sh %.xml,$(SOURCE_FILES)) 10 | TRANSLATION_MODULE ?= 11 | DEBUGMODE_MODULE ?= $(SRC_DIR)/modules/util.js 12 | -------------------------------------------------------------------------------- /build/gnome-extension.mk: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Deminder 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | SHELL := /bin/bash 5 | 6 | METADATA_FILE := $(SRC_DIR)/metadata.json 7 | ifeq ($(wildcard $(METADATA_FILE)),) 8 | $(error No extension metadata file found: $(METADATA_FILE)!) 9 | endif 10 | getMeta = $(shell grep "$(1)" $(METADATA_FILE) | cut -d\" -f 4) 11 | 12 | 13 | PACKAGE_NAME := $(call getMeta,name) 14 | UUID := $(call getMeta,uuid) 15 | GETTEXTDOMAIN := $(call getMeta,gettext-domain) 16 | SCHEMA_FILE := $(SRC_DIR)/schemas/$(call getMeta,settings-schema).gschema.xml 17 | ifeq ($(wildcard $(SCHEMA_FILE)),) 18 | $(info No settings schema found: SCHEMA_FILE) 19 | endif 20 | VERSION := $(shell grep -oP '^ *?\"version\": *?\K(\d+)' $(SRC_DIR)/metadata.json) 21 | 22 | LOCALE_DIR := $(SRC_DIR)/locale 23 | MO_FILES := $(patsubst $(PO_DIR)/%.po,$(LOCALE_DIR)/%/LC_MESSAGES/$(GETTEXTDOMAIN).mo,$(PO_FILES)) 24 | GSCHEMAS := $(wildcard $(SRC_DIR)/schemas/*.gschema.xml) 25 | GSCHEMAS_COMPILED := $(SRC_DIR)/schemas/gschemas.compiled 26 | 27 | ZIP_FILE := $(UUID).shell-extension.zip 28 | TARGET_DIR := target 29 | target-zip=$(patsubst %,$(TARGET_DIR)/%/$(ZIP_FILE),$(1)) 30 | DEFAULT_ZIP := $(call target-zip,default) 31 | DEBUG_ZIP := $(call target-zip,debug) 32 | 33 | all: $(DEFAULT_ZIP) $(DEBUG_ZIP) 34 | 35 | 36 | .SILENT .NOTPARALLEL .ONESHELL: $(DEFAULT_ZIP) $(DEBUG_ZIP) 37 | $(DEFAULT_ZIP) $(DEBUG_ZIP): $(SOURCE_FILES) $(GSCHEMAS) $(GSCHEMAS_COMPILED) 38 | set -e 39 | mkdir -p $(@D) 40 | function setConst() { 41 | local mtime=$$(stat -c %y "$$1") 42 | sed -Ei "s/^((export )?const $$2 = ).*?;/\1$$3;/" "$$1" 43 | touch -d "$$mtime" "$$1" 44 | echo $$1: "$$(grep -E 'const '$$2 $$1)" 45 | } 46 | ifneq ($(strip $(TRANSLATION_MODULE)),) 47 | setConst $(TRANSLATION_MODULE) domain \'$(GETTEXTDOMAIN)\' 48 | endif 49 | trap "setConst $(DEBUGMODE_MODULE) debugMode false" EXIT 50 | setConst $(DEBUGMODE_MODULE) debugMode $(shell [ $(@D) = $(TARGET_DIR)/debug ] && echo "true" || echo "false") 51 | 52 | echo -n "Packing $(ZIP_FILE) version $(VERSION) ... " 53 | (cd $(SRC_DIR) && zip -r - . 2>/dev/null) > "$@" 54 | zip -r "$@" LICENSES 2>&1 >/dev/null 55 | echo [OK] 56 | 57 | zip: $(DEFAULT_ZIP) 58 | debug-zip: $(DEBUG_ZIP) 59 | 60 | $(POT_MAIN): $(TRANSLATABLE_FILES) 61 | @echo "Collecting translatable strings..." 62 | @xgettext \ 63 | --from-code=UTF-8 \ 64 | --copyright-holder="$(PACKAGE_NAME)" \ 65 | --package-name="$(PACKAGE_NAME)" \ 66 | --package-version="$(VERSION)" \ 67 | --keyword="gtxt" \ 68 | --keyword="_n:1,2" \ 69 | --keyword="C_:1c,2" \ 70 | --output="$@" \ 71 | $(sort $^) 72 | 73 | $(PO_FILES): $(POT_MAIN) 74 | @echo -n $(patsubst %.po,%,$(notdir $@)) 75 | @msgmerge -U $@ $< 76 | @touch $@ 77 | 78 | $(MO_FILES): $(LOCALE_DIR)/%/LC_MESSAGES/$(GETTEXTDOMAIN).mo: $(PO_DIR)/%.po 79 | @mkdir -p $(@D) 80 | @msgfmt $< --output-file="$@" && echo "$(basename $(notdir $<)) [OK]" 81 | @touch $@ 82 | 83 | $(GSCHEMAS_COMPILED): $(GSCHEMAS) 84 | glib-compile-schemas --targetdir="$(@D)" $(SRC_DIR)/schemas 85 | 86 | po-lint: 87 | $(let unclear,\ 88 | $(shell grep -l "#, fuzzy" $(PO_FILES)),\ 89 | $(if $(unclear),\ 90 | @echo WARNING: Translations have unclear strings and need an update:\ 91 | $(patsubst %.po,%,$(notdir $(unclear))) && exit 1)) 92 | 93 | .ONESHELL: 94 | lint: 95 | @set -e 96 | reuse lint 97 | npm run lint 98 | npm run prettier 99 | 100 | format: 101 | npm run lint:fix 102 | npm run prettier:fix 103 | 104 | define INSTALL_EXTENSION 105 | .PHONY: $(1) 106 | $(1): $(2) 107 | @echo "Install extension$(3)..." 108 | @gnome-extensions install --force $(2) && \ 109 | echo "Extension is installed$(3). Now restart the GNOME Shell." || (echo "ERROR: Could not install the extension!" && exit 1) 110 | endef 111 | 112 | $(eval $(call INSTALL_EXTENSION,install,$(DEFAULT_ZIP),)) 113 | $(eval $(call INSTALL_EXTENSION,debug-install,$(DEBUG_ZIP), with debug enabled)) 114 | 115 | .ONESHELL .SILENT .PHONY: supported-install 116 | supported-install: 117 | set -e 118 | function hasVersionSupport() { 119 | python -c 'import json; import sys; any(sys.argv[1].startswith(v) for v in json.load(sys.stdin)["shell-version"]) or exit(1)' "$$1" 120 | } 121 | GNOME_VERSION=$$(gnome-shell --version | cut -d' ' -f3) 122 | if cat $(SRC_DIR)/metadata.json | hasVersionSupport "$$GNOME_VERSION" 123 | then 124 | make install 125 | else 126 | if [ -d .git ] 127 | then 128 | for version in {$(VERSION)..15} 129 | do 130 | tag=$$(git tag -l | grep -E "^(r|v)$$version$$" | head -n 1) 131 | if git show $${tag}:$(SRC_DIR)/metadata.json 2>/dev/null | hasVersionSupport "$$GNOME_VERSION" 132 | then 133 | git checkout "$$tag" || ( echo -e "\n\nFAILED install: could not checkout $${tag}!\n" && exit 1 ) 134 | echo -e "\n\nInstalling $$tag for GNOME shell $$GNOME_VERSION" 135 | make install 136 | exit 0 137 | fi 138 | done 139 | fi 140 | fi 141 | echo "FAILED: No support for GNOME shell $$GNOME_VERSION" && exit 1 142 | 143 | translations: $(MO_FILES) | po-lint 144 | 145 | NEXT_VERSION = $(shell echo 1 + $(VERSION) | bc) 146 | release: $(DEFAULT_ZIP) | translations lint 147 | set -e 148 | echo "Release version $(NEXT_VERSION)" 149 | # Set version in metadata file 150 | sed -Ei "s/(^ *?\"version\": *?)([0-9]+)(.*)/\1$(NEXT_VERSION)\3/" $(METADATA_FILE) 151 | @git add $(PO_DIR) $(METADATA_FILE) 152 | @git commit -am "Bump version to $(NEXT_VERSION)" 153 | @git tag -a "v$(NEXT_VERSION)" -m "Release version $(NEXT_VERSION)" 154 | 155 | GUEST_SSHADDR ?= guest 156 | GUEST_SSHCMD ?= ssh 157 | debug-guest: $(DEBUG_ZIP) 158 | @echo Install $< on '$(GUEST_SSHADDR)' via '$(GUEST_SSHCMD)' 159 | @rsync -e "$(GUEST_SSHCMD)" $< $(GUEST_SSHADDR):~/Downloads/ 160 | @$(GUEST_SSHCMD) "$(GUEST_SSHADDR)" "gnome-extensions install --force ~/Downloads/$(notdir $<) && killall -SIGQUIT gnome-shell" 161 | 162 | clean: 163 | -rm -rf $(LOCALE_DIR) 164 | -rm -rf $(TARGET_DIR) 165 | -rm -f $(GSCHEMAS_COMPILED) 166 | 167 | .PHONY: release clean translations lint po-lint zip debug-zip install 168 | -------------------------------------------------------------------------------- /data/img/externalScheduleFeature.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Deminder/ShutdownTimer/90377f0b378d9f0ff5bd8fe1731b9dcadccf0910/data/img/externalScheduleFeature.png -------------------------------------------------------------------------------- /data/img/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Deminder/ShutdownTimer/90377f0b378d9f0ff5bd8fe1731b9dcadccf0910/data/img/screenshot.png -------------------------------------------------------------------------------- /data/img/shutdown-timer-path.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 35 | 38 | 39 | 41 | 44 | 48 | 52 | 56 | 64 | 73 | 82 | 91 | 100 | 109 | 110 | 118 | 119 | 120 | 123 | 127 | 131 | 140 | 149 | 158 | 159 | 160 | 161 | 165 | 175 | 182 | 183 | 187 | 188 | -------------------------------------------------------------------------------- /data/img/shutdown-timer-symbolic-full.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 35 | 38 | 39 | 41 | 44 | 48 | 52 | 56 | 64 | 73 | 82 | 91 | 100 | 109 | 110 | 118 | 119 | 120 | 123 | 127 | 131 | 140 | 149 | 158 | 159 | 160 | 161 | 166 | 167 | -------------------------------------------------------------------------------- /data/img/wakeInfoFeature.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Deminder/ShutdownTimer/90377f0b378d9f0ff5bd8fe1731b9dcadccf0910/data/img/wakeInfoFeature.png -------------------------------------------------------------------------------- /lint/eslintrc-extension.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 Romain Vigier 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | rules: 5 | no-unused-vars: 6 | - error 7 | - vars: 'local' 8 | varsIgnorePattern: (^unused|_$) 9 | argsIgnorePattern: ^(unused|_) 10 | 11 | -------------------------------------------------------------------------------- /lint/eslintrc-gjs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # SPDX-License-Identifier: MIT OR LGPL-2.0-or-later 3 | # SPDX-FileCopyrightText: 2018 Claudio André 4 | env: 5 | es2021: true 6 | extends: 'eslint:recommended' 7 | plugins: 8 | - jsdoc 9 | rules: 10 | array-bracket-newline: 11 | - error 12 | - consistent 13 | array-bracket-spacing: 14 | - error 15 | - never 16 | array-callback-return: error 17 | arrow-parens: 18 | - error 19 | - as-needed 20 | arrow-spacing: error 21 | block-scoped-var: error 22 | block-spacing: error 23 | brace-style: error 24 | # Waiting for this to have matured a bit in eslint 25 | # camelcase: 26 | # - error 27 | # - properties: never 28 | # allow: [^vfunc_, ^on_, _instance_init] 29 | comma-dangle: 30 | - error 31 | - arrays: always-multiline 32 | objects: always-multiline 33 | functions: never 34 | comma-spacing: 35 | - error 36 | - before: false 37 | after: true 38 | comma-style: 39 | - error 40 | - last 41 | computed-property-spacing: error 42 | curly: 43 | - error 44 | - multi-or-nest 45 | - consistent 46 | dot-location: 47 | - error 48 | - property 49 | eol-last: error 50 | eqeqeq: error 51 | func-call-spacing: error 52 | func-name-matching: error 53 | func-style: 54 | - error 55 | - declaration 56 | - allowArrowFunctions: true 57 | indent: 58 | - error 59 | - 4 60 | - ignoredNodes: 61 | # Allow not indenting the body of GObject.registerClass, since in the 62 | # future it's intended to be a decorator 63 | - 'CallExpression[callee.object.name=GObject][callee.property.name=registerClass] > ClassExpression:first-child' 64 | # Allow dedenting chained member expressions 65 | MemberExpression: 'off' 66 | jsdoc/check-alignment: error 67 | jsdoc/check-param-names: error 68 | jsdoc/check-tag-names: error 69 | jsdoc/check-types: error 70 | jsdoc/implements-on-classes: error 71 | jsdoc/newline-after-description: error 72 | #jsdoc/require-jsdoc: error 73 | #jsdoc/require-param: error 74 | #jsdoc/require-param-description: error 75 | #jsdoc/require-param-name: error 76 | #jsdoc/require-param-type: error 77 | key-spacing: 78 | - error 79 | - beforeColon: false 80 | afterColon: true 81 | keyword-spacing: 82 | - error 83 | - before: true 84 | after: true 85 | linebreak-style: 86 | - error 87 | - unix 88 | lines-between-class-members: 89 | - error 90 | - always 91 | - exceptAfterSingleLine: true 92 | max-nested-callbacks: error 93 | max-statements-per-line: error 94 | new-parens: error 95 | no-array-constructor: error 96 | no-await-in-loop: error 97 | no-caller: error 98 | no-constant-condition: 99 | - error 100 | - checkLoops: false 101 | no-div-regex: error 102 | no-empty: 103 | - error 104 | - allowEmptyCatch: true 105 | no-extra-bind: error 106 | no-extra-parens: 107 | - error 108 | - all 109 | - conditionalAssign: false 110 | nestedBinaryExpressions: false 111 | returnAssign: false 112 | no-implicit-coercion: 113 | - error 114 | - allow: 115 | - '!!' 116 | no-invalid-this: error 117 | no-iterator: error 118 | no-label-var: error 119 | no-lonely-if: error 120 | no-loop-func: error 121 | #no-nested-ternary: error 122 | no-new-object: error 123 | no-new-wrappers: error 124 | no-octal-escape: error 125 | no-proto: error 126 | no-prototype-builtins: 'off' 127 | no-restricted-globals: [error, window] 128 | no-restricted-properties: 129 | - error 130 | - object: imports 131 | property: format 132 | message: Use template strings 133 | - object: pkg 134 | property: initFormat 135 | message: Use template strings 136 | - object: Lang 137 | property: copyProperties 138 | message: Use Object.assign() 139 | - object: Lang 140 | property: bind 141 | message: Use arrow notation or Function.prototype.bind() 142 | - object: Lang 143 | property: Class 144 | message: Use ES6 classes 145 | no-restricted-syntax: 146 | - error 147 | - selector: >- 148 | MethodDefinition[key.name="_init"] > 149 | FunctionExpression[params.length=1] > 150 | BlockStatement[body.length=1] 151 | CallExpression[arguments.length=1][callee.object.type="Super"][callee.property.name="_init"] > 152 | Identifier:first-child 153 | message: _init() that only calls super._init() is unnecessary 154 | - selector: >- 155 | MethodDefinition[key.name="_init"] > 156 | FunctionExpression[params.length=0] > 157 | BlockStatement[body.length=1] 158 | CallExpression[arguments.length=0][callee.object.type="Super"][callee.property.name="_init"] 159 | message: _init() that only calls super._init() is unnecessary 160 | - selector: BinaryExpression[operator="instanceof"][right.name="Array"] 161 | message: Use Array.isArray() 162 | no-return-assign: error 163 | no-return-await: error 164 | no-self-compare: error 165 | no-shadow: error 166 | no-shadow-restricted-names: error 167 | no-spaced-func: error 168 | no-tabs: error 169 | no-template-curly-in-string: error 170 | no-throw-literal: error 171 | no-trailing-spaces: error 172 | no-undef-init: error 173 | no-unneeded-ternary: error 174 | no-unused-expressions: error 175 | no-unused-vars: 176 | - error 177 | # Vars use a suffix _ instead of a prefix because of file-scope private vars 178 | - varsIgnorePattern: (^unused|_$) 179 | argsIgnorePattern: ^(unused|_) 180 | no-useless-call: error 181 | no-useless-computed-key: error 182 | no-useless-concat: error 183 | no-useless-constructor: error 184 | no-useless-rename: error 185 | no-useless-return: error 186 | no-whitespace-before-property: error 187 | no-with: error 188 | nonblock-statement-body-position: 189 | - error 190 | - below 191 | object-curly-newline: 192 | - error 193 | - consistent: true 194 | multiline: true 195 | object-curly-spacing: error 196 | object-shorthand: error 197 | operator-assignment: error 198 | operator-linebreak: error 199 | padded-blocks: 200 | - error 201 | - never 202 | # These may be a bit controversial, we can try them out and enable them later 203 | # prefer-const: error 204 | # prefer-destructuring: error 205 | prefer-numeric-literals: error 206 | prefer-promise-reject-errors: error 207 | prefer-rest-params: error 208 | prefer-spread: error 209 | prefer-template: error 210 | quotes: 211 | - error 212 | - single 213 | - avoidEscape: true 214 | require-await: error 215 | rest-spread-spacing: error 216 | semi: 217 | - error 218 | - always 219 | semi-spacing: 220 | - error 221 | - before: false 222 | after: true 223 | semi-style: error 224 | space-before-blocks: error 225 | space-before-function-paren: 226 | - error 227 | - named: never 228 | # for `function ()` and `async () =>`, preserve space around keywords 229 | anonymous: always 230 | asyncArrow: always 231 | space-in-parens: error 232 | space-infix-ops: 233 | - error 234 | - int32Hint: false 235 | space-unary-ops: error 236 | spaced-comment: error 237 | switch-colon-spacing: error 238 | symbol-description: error 239 | template-curly-spacing: error 240 | template-tag-spacing: error 241 | unicode-bom: error 242 | wrap-iife: 243 | - error 244 | - inside 245 | yield-star-spacing: error 246 | yoda: error 247 | settings: 248 | jsdoc: 249 | mode: typescript 250 | globals: 251 | ARGV: readonly 252 | Debugger: readonly 253 | GIRepositoryGType: readonly 254 | globalThis: readonly 255 | global: readonly 256 | imports: readonly 257 | Intl: readonly 258 | log: readonly 259 | logError: readonly 260 | print: readonly 261 | printerr: readonly 262 | window: readonly 263 | TextEncoder: readonly 264 | TextDecoder: readonly 265 | console: readonly 266 | setTimeout: readonly 267 | setInterval: readonly 268 | clearTimeout: readonly 269 | clearInterval: readonly 270 | parserOptions: 271 | ecmaVersion: 2022 272 | 273 | -------------------------------------------------------------------------------- /lint/eslintrc-shell.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: GNOME Shell developers 2 | # SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | # From https://gitlab.gnome.org/GNOME/gnome-shell/ 5 | rules: 6 | camelcase: 7 | - error 8 | - properties: never 9 | allow: [^vfunc_, ^on_] 10 | consistent-return: error 11 | key-spacing: 12 | - error 13 | - mode: minimum 14 | beforeColon: false 15 | afterColon: true 16 | object-curly-spacing: 17 | - error 18 | - always 19 | prefer-arrow-callback: error 20 | parserOptions: 21 | sourceType: module 22 | 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "lint": "npx eslint src", 4 | "lint:fix": "npm run lint -- --fix", 5 | "prettier": "npx prettier src --check", 6 | "prettier:fix": "npm run prettier -- --write", 7 | "format": "npm run prettier:fix && npm run lint:fix" 8 | }, 9 | "devDependencies": { 10 | "eslint": "^8.23.0", 11 | "eslint-config-prettier": "^8.5.0", 12 | "eslint-plugin-jsdoc": "^39.3.6", 13 | "prettier": "^2.7.1" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /package.json.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2023 Deminder 2 | 3 | SPDX-License-Identifier: GPL-3.0-or-later 4 | -------------------------------------------------------------------------------- /po/es.po: -------------------------------------------------------------------------------- 1 | # 2 | # AUTHOR: Daniel Neumann 3 | # 4 | msgid "" 5 | msgstr "" 6 | "Project-Id-Version: Shutdown Timer GNOME Shell Extension\n" 7 | "Report-Msgid-Bugs-To: \n" 8 | "POT-Creation-Date: 2025-05-30 19:33+0200\n" 9 | "PO-Revision-Date: 2022-04-07 22:06+0200\n" 10 | "Last-Translator: Daniel Neumann\n" 11 | "Language-Team: Jesús Ignacio García \n" 12 | "Language: es\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 17 | "X-Generator: Poedit 3.0.1\n" 18 | "X-Poedit-SourceCharset: UTF-8\n" 19 | "X-Poedit-KeywordsList: _\n" 20 | "X-Poedit-Basepath: ../../..\n" 21 | "X-Poedit-SearchPath-0: .\n" 22 | 23 | #: src/dbus-service/action.js:177 24 | msgid "Suspend then Hibernate" 25 | msgstr "" 26 | 27 | #: src/dbus-service/action.js:178 28 | msgid "Hybrid Sleep" 29 | msgstr "" 30 | 31 | #: src/dbus-service/action.js:179 32 | msgid "Hibernate" 33 | msgstr "" 34 | 35 | #: src/dbus-service/action.js:180 36 | msgid "Halt" 37 | msgstr "" 38 | 39 | #: src/dbus-service/action.js:181 40 | msgid "Suspend" 41 | msgstr "" 42 | 43 | #: src/dbus-service/action.js:182 44 | msgid "Power Off" 45 | msgstr "" 46 | 47 | #: src/dbus-service/action.js:183 48 | msgid "Restart" 49 | msgstr "" 50 | 51 | #: src/dbus-service/action.js:184 src/ui/prefs.ui:239 src/ui/prefs.ui:442 52 | msgid "Wake" 53 | msgstr "" 54 | 55 | #: src/dbus-service/action.js:185 56 | msgid "No Wake" 57 | msgstr "" 58 | 59 | #: src/dbus-service/action.js:191 60 | msgctxt "untiltext" 61 | msgid "suspend and hibernate" 62 | msgstr "" 63 | 64 | #: src/dbus-service/action.js:192 65 | msgctxt "untiltext" 66 | msgid "hybrid sleep" 67 | msgstr "" 68 | 69 | #: src/dbus-service/action.js:193 70 | msgctxt "untiltext" 71 | msgid "hibernate" 72 | msgstr "" 73 | 74 | #: src/dbus-service/action.js:194 75 | msgctxt "untiltext" 76 | msgid "halt" 77 | msgstr "" 78 | 79 | #: src/dbus-service/action.js:195 80 | msgctxt "untiltext" 81 | msgid "suspend" 82 | msgstr "" 83 | 84 | #: src/dbus-service/action.js:196 85 | msgctxt "untiltext" 86 | msgid "shutdown" 87 | msgstr "apagado." 88 | 89 | #: src/dbus-service/action.js:197 90 | msgctxt "untiltext" 91 | msgid "reboot" 92 | msgstr "" 93 | 94 | #: src/dbus-service/action.js:198 95 | msgctxt "untiltext" 96 | msgid "wakeup" 97 | msgstr "" 98 | 99 | #: src/dbus-service/control.js:72 100 | msgid "Can not cancel root process!" 101 | msgstr "" 102 | 103 | #: src/dbus-service/control.js:74 104 | msgid "CANCEL" 105 | msgstr "" 106 | 107 | #: src/dbus-service/control.js:200 108 | msgid "Privileged script installation required!" 109 | msgstr "" 110 | 111 | #: src/dbus-service/timer.js:105 112 | #, javascript-format 113 | msgctxt "StartSchedulePopup" 114 | msgid "%s in %s" 115 | msgstr "%s en %s" 116 | 117 | #: src/dbus-service/timer.js:109 src/modules/menu-item.js:388 118 | #: src/modules/schedule-info.js:123 119 | #, javascript-format 120 | msgid "%s hour" 121 | msgid_plural "%s hours" 122 | msgstr[0] "%s hora" 123 | msgstr[1] "%s horas" 124 | 125 | #: src/dbus-service/timer.js:110 src/modules/menu-item.js:389 126 | #, javascript-format 127 | msgid "%s minute" 128 | msgid_plural "%s minutes" 129 | msgstr[0] "%s minuto" 130 | msgstr[1] "%s minutos" 131 | 132 | #: src/dbus-service/timer.js:134 133 | msgid "Shutdown Timer stopped" 134 | msgstr "Temporizador apagado." 135 | 136 | #: src/dbus-service/timer.js:170 137 | #, javascript-format 138 | msgid "%s is not supported!" 139 | msgstr "" 140 | 141 | #: src/dbus-service/timer.js:208 src/dbus-service/timer.js:278 142 | #, javascript-format 143 | msgctxt "Error" 144 | msgid "" 145 | "%s\n" 146 | "%s" 147 | msgstr "" 148 | 149 | #: src/dbus-service/timer.js:208 150 | msgid "Wake action failed!" 151 | msgstr "" 152 | 153 | #: src/dbus-service/timer.js:220 154 | #, javascript-format 155 | msgid "Unknown shutdown action: \"%s\"!" 156 | msgstr "" 157 | 158 | #: src/dbus-service/timer.js:278 159 | msgid "Root mode protection failed!" 160 | msgstr "" 161 | 162 | #: src/modules/install.js:32 163 | msgid "START" 164 | msgstr "" 165 | 166 | #: src/modules/install.js:42 167 | msgid "END" 168 | msgstr "" 169 | 170 | #: src/modules/install.js:44 171 | msgid "FAIL" 172 | msgstr "" 173 | 174 | #: src/modules/install.js:62 175 | msgid "install" 176 | msgstr "" 177 | 178 | #: src/modules/install.js:62 179 | msgid "uninstall" 180 | msgstr "" 181 | 182 | #: src/modules/menu-item.js:84 src/modules/menu-item.js:301 183 | msgid "Shutdown Timer" 184 | msgstr "Apagar con temporizador." 185 | 186 | #: src/modules/menu-item.js:119 187 | msgid "Settings" 188 | msgstr "" 189 | 190 | #: src/modules/menu-item.js:271 191 | msgid "now" 192 | msgstr "" 193 | 194 | #: src/modules/menu-item.js:346 src/modules/menu-item.js:385 195 | msgctxt "absolute time notation" 196 | msgid "%a, %R" 197 | msgstr "" 198 | 199 | #: src/modules/menu-item.js:349 200 | #, javascript-format 201 | msgid "%s hr" 202 | msgid_plural "%s hrs" 203 | msgstr[0] "" 204 | msgstr[1] "" 205 | 206 | #: src/modules/menu-item.js:350 src/modules/schedule-info.js:129 207 | #, javascript-format 208 | msgid "%s min" 209 | msgid_plural "%s mins" 210 | msgstr[0] "" 211 | msgstr[1] "" 212 | 213 | #: src/modules/menu-item.js:356 214 | #, javascript-format 215 | msgid "%s (protect)" 216 | msgstr "" 217 | 218 | #: src/modules/menu-item.js:380 219 | #, javascript-format 220 | msgctxt "WakeButtonText" 221 | msgid "%s at %s" 222 | msgstr "" 223 | 224 | #: src/modules/menu-item.js:381 225 | #, javascript-format 226 | msgctxt "WakeButtonText" 227 | msgid "%s after %s" 228 | msgstr "" 229 | 230 | #: src/modules/schedule-info.js:44 231 | msgid "{durationString} until {untiltext}" 232 | msgstr "{durationString} para el {untiltext}" 233 | 234 | #: src/modules/schedule-info.js:48 235 | msgid "{label} (sys)" 236 | msgstr "" 237 | 238 | #: src/modules/schedule-info.js:57 239 | msgctxt "absolute schedule notation" 240 | msgid "%a, %T" 241 | msgstr "" 242 | 243 | #: src/modules/schedule-info.js:125 244 | #, javascript-format 245 | msgid "%s sec" 246 | msgid_plural "%s secs" 247 | msgstr[0] "" 248 | msgstr[1] "" 249 | 250 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:13 251 | msgid "Maximum shutdown time (in minutes)" 252 | msgstr "" 253 | 254 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:14 255 | msgid "" 256 | "Set maximum selectable shutdown time of the slider (in minutes). Use only " 257 | "values greater zero." 258 | msgstr "" 259 | 260 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:19 261 | msgid "Maximum wake time (in minutes)" 262 | msgstr "" 263 | 264 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:20 265 | msgid "" 266 | "Set maximum selectable wake time of the slider (in minutes). Use only values " 267 | "greater zero." 268 | msgstr "" 269 | 270 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:25 271 | msgid "Reference start time for shutdown" 272 | msgstr "" 273 | 274 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:26 275 | msgid "Reference start time for shutdown either now or HH:MM" 276 | msgstr "" 277 | 278 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:31 279 | msgid "Display shutdown time as absolute timestamp" 280 | msgstr "" 281 | 282 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:32 283 | msgid "Display shutdown time as absolute timestamp \"HH:MM\"" 284 | msgstr "" 285 | 286 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:37 287 | msgid "Reference start time for wake" 288 | msgstr "" 289 | 290 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:38 291 | msgid "Reference start time for wake either now, shutdown, or HH:MM" 292 | msgstr "" 293 | 294 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:43 295 | msgid "Display wake time as absolute timestamp" 296 | msgstr "" 297 | 298 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:44 299 | msgid "Display wake time as absolute timestamp \"HH:MM\"" 300 | msgstr "" 301 | 302 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:49 303 | msgid "Automatically start and stop wake on shutdown timer toggle" 304 | msgstr "" 305 | 306 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:50 307 | msgid "" 308 | "Enable/Disable the wake alarm when the shutdown timer is started/stopped." 309 | msgstr "" 310 | 311 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:55 312 | msgid "Scheduled shutdown timestamp." 313 | msgstr "" 314 | 315 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:56 316 | msgid "Unix time in seconds of scheduled shutdown or -1 if disabled." 317 | msgstr "" 318 | 319 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:61 320 | msgid "Wake slider position (in percent)" 321 | msgstr "" 322 | 323 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:62 324 | msgid "" 325 | "Set wake slider position as percent of the maximum time. Must be in range 0 " 326 | "and 100." 327 | msgstr "" 328 | 329 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:67 330 | msgid "Ramp-up of non-linear wake slider value" 331 | msgstr "" 332 | 333 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:68 334 | msgid "Exponential ramp-up for wake time slider" 335 | msgstr "" 336 | 337 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:73 338 | msgid "Shutdown slider position (in percent)" 339 | msgstr "" 340 | 341 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:74 342 | msgid "" 343 | "Set shutdown slider position as percent of the maximum time. Must be in " 344 | "range 0 and 100." 345 | msgstr "" 346 | 347 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:79 348 | msgid "Ramp-up of non-linear shutdown slider value" 349 | msgstr "" 350 | 351 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:80 352 | msgid "Exponential ramp-up for shutdown time slider" 353 | msgstr "" 354 | 355 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:85 356 | #: src/ui/prefs.ui:360 357 | msgid "Show settings button" 358 | msgstr "" 359 | 360 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:86 361 | msgid "Show/hide settings button in widget." 362 | msgstr "" 363 | 364 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:91 365 | #: src/ui/prefs.ui:404 366 | msgid "Show shutdown slider" 367 | msgstr "" 368 | 369 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:92 370 | msgid "Show/hide shutdown slider in widget." 371 | msgstr "" 372 | 373 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:97 374 | #: src/ui/prefs.ui:458 375 | msgid "Show wake slider" 376 | msgstr "" 377 | 378 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:98 379 | msgid "Show/hide wake slider in widget." 380 | msgstr "" 381 | 382 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:103 383 | msgid "Show all wake items" 384 | msgstr "" 385 | 386 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:104 387 | msgid "Show/hide all wake items in widget." 388 | msgstr "" 389 | 390 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:109 391 | #: src/ui/prefs.ui:372 392 | msgid "Show notification text boxes" 393 | msgstr "" 394 | 395 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:110 396 | msgid "Show/hide notification text boxes on screen." 397 | msgstr "" 398 | 399 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:115 400 | msgid "Root mode" 401 | msgstr "" 402 | 403 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:116 404 | msgid "" 405 | "Set root mode on/off. In root mode powering off is done via 'pkexec' and " 406 | "'shutdown' terminal command." 407 | msgstr "" 408 | 409 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:121 410 | #: src/ui/prefs.ui:122 411 | msgid "Show end-session dialog" 412 | msgstr "" 413 | 414 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:122 415 | msgid "" 416 | "Show the end-session dialog for reboot and shutdown if screensaver is " 417 | "inactive." 418 | msgstr "" 419 | 420 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:127 421 | msgid "Shown shutdown modes" 422 | msgstr "" 423 | 424 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:128 425 | #: src/ui/prefs.ui:390 426 | msgid "" 427 | "Comma-separated shutdown modes which are shown in the popup menu (p: " 428 | "poweroff, s: suspend, r: reboot)" 429 | msgstr "" 430 | 431 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:133 432 | #: src/ui/prefs.ui:116 433 | msgid "Use mode" 434 | msgstr "" 435 | 436 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:134 437 | #: src/ui/prefs.ui:117 438 | msgid "Mode to use for timer action" 439 | msgstr "" 440 | 441 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:139 442 | #: src/ui/prefs.ui:416 443 | msgid "Show shutdown indicator" 444 | msgstr "" 445 | 446 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:140 447 | #: src/ui/prefs.ui:417 448 | msgid "Shows the remaining time until shutdown action" 449 | msgstr "" 450 | 451 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:145 452 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:146 453 | msgid "Last selected page in the preferences." 454 | msgstr "" 455 | 456 | #: src/tool/installer.sh:66 457 | msgid "Failed" 458 | msgstr "" 459 | 460 | #: src/tool/installer.sh:68 461 | msgid "Success" 462 | msgstr "" 463 | 464 | #: src/tool/installer.sh:192 src/tool/installer.sh:199 465 | #: src/tool/installer.sh:204 466 | msgid "Installing" 467 | msgstr "" 468 | 469 | #: src/tool/installer.sh:192 src/tool/installer.sh:226 470 | #: src/tool/installer.sh:254 471 | msgid "tool" 472 | msgstr "" 473 | 474 | #: src/tool/installer.sh:198 475 | msgid "Using legacy policykit install" 476 | msgstr "" 477 | 478 | #: src/tool/installer.sh:199 src/tool/installer.sh:233 479 | msgid "policykit action" 480 | msgstr "" 481 | 482 | #: src/tool/installer.sh:204 src/tool/installer.sh:240 483 | #: src/tool/installer.sh:255 484 | msgid "policykit rule" 485 | msgstr "" 486 | 487 | #: src/tool/installer.sh:226 src/tool/installer.sh:233 488 | #: src/tool/installer.sh:240 src/tool/installer.sh:245 489 | msgid "Uninstalling" 490 | msgstr "" 491 | 492 | #: src/tool/installer.sh:227 src/tool/installer.sh:234 493 | #: src/tool/installer.sh:241 src/tool/installer.sh:248 494 | msgid "cannot remove" 495 | msgstr "" 496 | 497 | #: src/tool/installer.sh:250 498 | msgid "not installed at" 499 | msgstr "" 500 | 501 | #: src/ui/prefs.ui:63 502 | msgid "Install" 503 | msgstr "" 504 | 505 | #: src/ui/prefs.ui:70 506 | msgid "Install/Uninstall privileges for this user" 507 | msgstr "" 508 | 509 | #: src/ui/prefs.ui:71 510 | msgid "Setup a privileged script and give user access via polkit" 511 | msgstr "" 512 | 513 | #: src/ui/prefs.ui:82 514 | msgid "Install output" 515 | msgstr "" 516 | 517 | #: src/ui/prefs.ui:109 src/ui/prefs.ui:386 518 | msgid "Shutdown" 519 | msgstr "Apagado" 520 | 521 | #: src/ui/prefs.ui:113 src/ui/prefs.ui:243 522 | msgid "Timer Action" 523 | msgstr "" 524 | 525 | #: src/ui/prefs.ui:123 526 | msgid "Shown for reboot and shutdown if screensaver is inactive" 527 | msgstr "" 528 | 529 | #: src/ui/prefs.ui:134 530 | msgid "Toggle root shutdown with shutdown timer" 531 | msgstr "" 532 | 533 | #: src/ui/prefs.ui:135 534 | msgid "Runs extra 'shutdown -P/-r' command for shutdown or reboot" 535 | msgstr "" 536 | 537 | #: src/ui/prefs.ui:148 src/ui/prefs.ui:261 538 | msgid "Timer Input" 539 | msgstr "" 540 | 541 | #: src/ui/prefs.ui:151 src/ui/prefs.ui:264 542 | msgid "Reference start time" 543 | msgstr "" 544 | 545 | #: src/ui/prefs.ui:152 546 | msgid "Options: now, HH:MM" 547 | msgstr "" 548 | 549 | #: src/ui/prefs.ui:165 src/ui/prefs.ui:183 550 | msgid "Shutdown slider position (in %)" 551 | msgstr "" 552 | 553 | #: src/ui/prefs.ui:199 src/ui/prefs.ui:312 554 | msgid "Non-linear scaling or 0 to disable" 555 | msgstr "" 556 | 557 | #: src/ui/prefs.ui:218 558 | msgid "Maximum shutdown timer value (in minutes)" 559 | msgstr "" 560 | 561 | #: src/ui/prefs.ui:247 562 | msgid "Toggle wake with timer action" 563 | msgstr "" 564 | 565 | #: src/ui/prefs.ui:265 566 | msgid "Options: now, shutdown, HH:MM" 567 | msgstr "" 568 | 569 | #: src/ui/prefs.ui:278 src/ui/prefs.ui:296 570 | msgid "Wake slider position (in %)" 571 | msgstr "" 572 | 573 | #: src/ui/prefs.ui:331 574 | msgid "Maximum wake timer value (in minutes)" 575 | msgstr "" 576 | 577 | #: src/ui/prefs.ui:352 578 | msgid "Display" 579 | msgstr "" 580 | 581 | #: src/ui/prefs.ui:356 582 | msgid "General" 583 | msgstr "" 584 | 585 | #: src/ui/prefs.ui:389 586 | msgid "Show shutdown items" 587 | msgstr "" 588 | 589 | #: src/ui/prefs.ui:429 590 | msgid "Absolute shutdown time selection" 591 | msgstr "" 592 | 593 | #: src/ui/prefs.ui:446 594 | msgid "Show wake items" 595 | msgstr "" 596 | 597 | #: src/ui/prefs.ui:470 598 | msgid "Absolute wake time selection" 599 | msgstr "" 600 | 601 | #~ msgctxt "checktext" 602 | #~ msgid "shutdown" 603 | #~ msgstr "apagado." 604 | 605 | #, javascript-format 606 | #~ msgctxt "StartSchedulePopup" 607 | #~ msgid "System will %s in %s" 608 | #~ msgstr "%s en %s" 609 | 610 | #~ msgid "hour" 611 | #~ msgid_plural "hours" 612 | #~ msgstr[0] "hora" 613 | #~ msgstr[1] "horas" 614 | 615 | #~ msgid "minute" 616 | #~ msgid_plural "minutes" 617 | #~ msgstr[0] "minuto" 618 | #~ msgstr[1] "minutos" 619 | 620 | #~ msgid "min" 621 | #~ msgid_plural "mins" 622 | #~ msgstr[0] "minuto" 623 | #~ msgstr[1] "minutos" 624 | 625 | #~ msgid "sec" 626 | #~ msgid_plural "secs" 627 | #~ msgstr[0] "segundo" 628 | #~ msgstr[1] "segundos" 629 | -------------------------------------------------------------------------------- /po/main.pot: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR Shutdown Timer 3 | # This file is distributed under the same license as the Shutdown Timer package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: Shutdown Timer 50\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2025-05-30 19:50+0200\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=CHARSET\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" 20 | 21 | #: src/dbus-service/action.js:177 22 | msgid "Suspend then Hibernate" 23 | msgstr "" 24 | 25 | #: src/dbus-service/action.js:178 26 | msgid "Hybrid Sleep" 27 | msgstr "" 28 | 29 | #: src/dbus-service/action.js:179 30 | msgid "Hibernate" 31 | msgstr "" 32 | 33 | #: src/dbus-service/action.js:180 34 | msgid "Halt" 35 | msgstr "" 36 | 37 | #: src/dbus-service/action.js:181 38 | msgid "Suspend" 39 | msgstr "" 40 | 41 | #: src/dbus-service/action.js:182 42 | msgid "Power Off" 43 | msgstr "" 44 | 45 | #: src/dbus-service/action.js:183 46 | msgid "Restart" 47 | msgstr "" 48 | 49 | #: src/dbus-service/action.js:184 src/ui/prefs.ui:239 src/ui/prefs.ui:442 50 | msgid "Wake" 51 | msgstr "" 52 | 53 | #: src/dbus-service/action.js:185 54 | msgid "No Wake" 55 | msgstr "" 56 | 57 | #: src/dbus-service/action.js:191 58 | msgctxt "untiltext" 59 | msgid "suspend and hibernate" 60 | msgstr "" 61 | 62 | #: src/dbus-service/action.js:192 63 | msgctxt "untiltext" 64 | msgid "hybrid sleep" 65 | msgstr "" 66 | 67 | #: src/dbus-service/action.js:193 68 | msgctxt "untiltext" 69 | msgid "hibernate" 70 | msgstr "" 71 | 72 | #: src/dbus-service/action.js:194 73 | msgctxt "untiltext" 74 | msgid "halt" 75 | msgstr "" 76 | 77 | #: src/dbus-service/action.js:195 78 | msgctxt "untiltext" 79 | msgid "suspend" 80 | msgstr "" 81 | 82 | #: src/dbus-service/action.js:196 83 | msgctxt "untiltext" 84 | msgid "shutdown" 85 | msgstr "" 86 | 87 | #: src/dbus-service/action.js:197 88 | msgctxt "untiltext" 89 | msgid "reboot" 90 | msgstr "" 91 | 92 | #: src/dbus-service/action.js:198 93 | msgctxt "untiltext" 94 | msgid "wakeup" 95 | msgstr "" 96 | 97 | #: src/dbus-service/control.js:72 98 | msgid "Can not cancel root process!" 99 | msgstr "" 100 | 101 | #: src/dbus-service/control.js:74 102 | msgid "CANCEL" 103 | msgstr "" 104 | 105 | #: src/dbus-service/control.js:200 106 | msgid "Privileged script installation required!" 107 | msgstr "" 108 | 109 | #: src/dbus-service/timer.js:105 110 | #, javascript-format 111 | msgctxt "StartSchedulePopup" 112 | msgid "%s in %s" 113 | msgstr "" 114 | 115 | #: src/dbus-service/timer.js:109 src/modules/menu-item.js:388 116 | #: src/modules/schedule-info.js:123 117 | #, javascript-format 118 | msgid "%s hour" 119 | msgid_plural "%s hours" 120 | msgstr[0] "" 121 | msgstr[1] "" 122 | 123 | #: src/dbus-service/timer.js:110 src/modules/menu-item.js:389 124 | #, javascript-format 125 | msgid "%s minute" 126 | msgid_plural "%s minutes" 127 | msgstr[0] "" 128 | msgstr[1] "" 129 | 130 | #: src/dbus-service/timer.js:134 131 | msgid "Shutdown Timer stopped" 132 | msgstr "" 133 | 134 | #: src/dbus-service/timer.js:170 135 | #, javascript-format 136 | msgid "%s is not supported!" 137 | msgstr "" 138 | 139 | #: src/dbus-service/timer.js:208 src/dbus-service/timer.js:278 140 | #, javascript-format 141 | msgctxt "Error" 142 | msgid "" 143 | "%s\n" 144 | "%s" 145 | msgstr "" 146 | 147 | #: src/dbus-service/timer.js:208 148 | msgid "Wake action failed!" 149 | msgstr "" 150 | 151 | #: src/dbus-service/timer.js:220 152 | #, javascript-format 153 | msgid "Unknown shutdown action: \"%s\"!" 154 | msgstr "" 155 | 156 | #: src/dbus-service/timer.js:278 157 | msgid "Root mode protection failed!" 158 | msgstr "" 159 | 160 | #: src/modules/install.js:32 161 | msgid "START" 162 | msgstr "" 163 | 164 | #: src/modules/install.js:42 165 | msgid "END" 166 | msgstr "" 167 | 168 | #: src/modules/install.js:44 169 | msgid "FAIL" 170 | msgstr "" 171 | 172 | #: src/modules/install.js:62 173 | msgid "install" 174 | msgstr "" 175 | 176 | #: src/modules/install.js:62 177 | msgid "uninstall" 178 | msgstr "" 179 | 180 | #: src/modules/menu-item.js:84 src/modules/menu-item.js:301 181 | msgid "Shutdown Timer" 182 | msgstr "" 183 | 184 | #: src/modules/menu-item.js:119 185 | msgid "Settings" 186 | msgstr "" 187 | 188 | #: src/modules/menu-item.js:271 189 | msgid "now" 190 | msgstr "" 191 | 192 | #: src/modules/menu-item.js:346 src/modules/menu-item.js:385 193 | msgctxt "absolute time notation" 194 | msgid "%a, %R" 195 | msgstr "" 196 | 197 | #: src/modules/menu-item.js:349 198 | #, javascript-format 199 | msgid "%s hr" 200 | msgid_plural "%s hrs" 201 | msgstr[0] "" 202 | msgstr[1] "" 203 | 204 | #: src/modules/menu-item.js:350 src/modules/schedule-info.js:129 205 | #, javascript-format 206 | msgid "%s min" 207 | msgid_plural "%s mins" 208 | msgstr[0] "" 209 | msgstr[1] "" 210 | 211 | #: src/modules/menu-item.js:356 212 | #, javascript-format 213 | msgid "%s (protect)" 214 | msgstr "" 215 | 216 | #: src/modules/menu-item.js:380 217 | #, javascript-format 218 | msgctxt "WakeButtonText" 219 | msgid "%s at %s" 220 | msgstr "" 221 | 222 | #: src/modules/menu-item.js:381 223 | #, javascript-format 224 | msgctxt "WakeButtonText" 225 | msgid "%s after %s" 226 | msgstr "" 227 | 228 | #: src/modules/schedule-info.js:44 229 | msgid "{durationString} until {untiltext}" 230 | msgstr "" 231 | 232 | #: src/modules/schedule-info.js:48 233 | msgid "{label} (sys)" 234 | msgstr "" 235 | 236 | #: src/modules/schedule-info.js:57 237 | msgctxt "absolute schedule notation" 238 | msgid "%a, %T" 239 | msgstr "" 240 | 241 | #: src/modules/schedule-info.js:125 242 | #, javascript-format 243 | msgid "%s sec" 244 | msgid_plural "%s secs" 245 | msgstr[0] "" 246 | msgstr[1] "" 247 | 248 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:13 249 | msgid "Maximum shutdown time (in minutes)" 250 | msgstr "" 251 | 252 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:14 253 | msgid "" 254 | "Set maximum selectable shutdown time of the slider (in minutes). Use only " 255 | "values greater zero." 256 | msgstr "" 257 | 258 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:19 259 | msgid "Maximum wake time (in minutes)" 260 | msgstr "" 261 | 262 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:20 263 | msgid "" 264 | "Set maximum selectable wake time of the slider (in minutes). Use only values " 265 | "greater zero." 266 | msgstr "" 267 | 268 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:25 269 | msgid "Reference start time for shutdown" 270 | msgstr "" 271 | 272 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:26 273 | msgid "Reference start time for shutdown either now or HH:MM" 274 | msgstr "" 275 | 276 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:31 277 | msgid "Display shutdown time as absolute timestamp" 278 | msgstr "" 279 | 280 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:32 281 | msgid "Display shutdown time as absolute timestamp \"HH:MM\"" 282 | msgstr "" 283 | 284 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:37 285 | msgid "Reference start time for wake" 286 | msgstr "" 287 | 288 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:38 289 | msgid "Reference start time for wake either now, shutdown, or HH:MM" 290 | msgstr "" 291 | 292 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:43 293 | msgid "Display wake time as absolute timestamp" 294 | msgstr "" 295 | 296 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:44 297 | msgid "Display wake time as absolute timestamp \"HH:MM\"" 298 | msgstr "" 299 | 300 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:49 301 | msgid "Automatically start and stop wake on shutdown timer toggle" 302 | msgstr "" 303 | 304 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:50 305 | msgid "" 306 | "Enable/Disable the wake alarm when the shutdown timer is started/stopped." 307 | msgstr "" 308 | 309 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:55 310 | msgid "Scheduled shutdown timestamp." 311 | msgstr "" 312 | 313 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:56 314 | msgid "Unix time in seconds of scheduled shutdown or -1 if disabled." 315 | msgstr "" 316 | 317 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:61 318 | msgid "Wake slider position (in percent)" 319 | msgstr "" 320 | 321 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:62 322 | msgid "" 323 | "Set wake slider position as percent of the maximum time. Must be in range 0 " 324 | "and 100." 325 | msgstr "" 326 | 327 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:67 328 | msgid "Ramp-up of non-linear wake slider value" 329 | msgstr "" 330 | 331 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:68 332 | msgid "Exponential ramp-up for wake time slider" 333 | msgstr "" 334 | 335 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:73 336 | msgid "Shutdown slider position (in percent)" 337 | msgstr "" 338 | 339 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:74 340 | msgid "" 341 | "Set shutdown slider position as percent of the maximum time. Must be in " 342 | "range 0 and 100." 343 | msgstr "" 344 | 345 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:79 346 | msgid "Ramp-up of non-linear shutdown slider value" 347 | msgstr "" 348 | 349 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:80 350 | msgid "Exponential ramp-up for shutdown time slider" 351 | msgstr "" 352 | 353 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:85 354 | #: src/ui/prefs.ui:360 355 | msgid "Show settings button" 356 | msgstr "" 357 | 358 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:86 359 | msgid "Show/hide settings button in widget." 360 | msgstr "" 361 | 362 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:91 363 | #: src/ui/prefs.ui:404 364 | msgid "Show shutdown slider" 365 | msgstr "" 366 | 367 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:92 368 | msgid "Show/hide shutdown slider in widget." 369 | msgstr "" 370 | 371 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:97 372 | #: src/ui/prefs.ui:458 373 | msgid "Show wake slider" 374 | msgstr "" 375 | 376 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:98 377 | msgid "Show/hide wake slider in widget." 378 | msgstr "" 379 | 380 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:103 381 | msgid "Show all wake items" 382 | msgstr "" 383 | 384 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:104 385 | msgid "Show/hide all wake items in widget." 386 | msgstr "" 387 | 388 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:109 389 | #: src/ui/prefs.ui:372 390 | msgid "Show notification text boxes" 391 | msgstr "" 392 | 393 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:110 394 | msgid "Show/hide notification text boxes on screen." 395 | msgstr "" 396 | 397 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:115 398 | msgid "Root mode" 399 | msgstr "" 400 | 401 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:116 402 | msgid "" 403 | "Set root mode on/off. In root mode powering off is done via 'pkexec' and " 404 | "'shutdown' terminal command." 405 | msgstr "" 406 | 407 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:121 408 | #: src/ui/prefs.ui:122 409 | msgid "Show end-session dialog" 410 | msgstr "" 411 | 412 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:122 413 | msgid "" 414 | "Show the end-session dialog for reboot and shutdown if screensaver is " 415 | "inactive." 416 | msgstr "" 417 | 418 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:127 419 | msgid "Shown shutdown modes" 420 | msgstr "" 421 | 422 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:128 423 | #: src/ui/prefs.ui:390 424 | msgid "" 425 | "Comma-separated shutdown modes which are shown in the popup menu (p: " 426 | "poweroff, s: suspend, r: reboot)" 427 | msgstr "" 428 | 429 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:133 430 | #: src/ui/prefs.ui:116 431 | msgid "Use mode" 432 | msgstr "" 433 | 434 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:134 435 | #: src/ui/prefs.ui:117 436 | msgid "Mode to use for timer action" 437 | msgstr "" 438 | 439 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:139 440 | #: src/ui/prefs.ui:416 441 | msgid "Show shutdown indicator" 442 | msgstr "" 443 | 444 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:140 445 | #: src/ui/prefs.ui:417 446 | msgid "Shows the remaining time until shutdown action" 447 | msgstr "" 448 | 449 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:145 450 | #: src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml:146 451 | msgid "Last selected page in the preferences." 452 | msgstr "" 453 | 454 | #: src/tool/installer.sh:66 455 | msgid "Failed" 456 | msgstr "" 457 | 458 | #: src/tool/installer.sh:68 459 | msgid "Success" 460 | msgstr "" 461 | 462 | #: src/tool/installer.sh:192 src/tool/installer.sh:199 463 | #: src/tool/installer.sh:204 464 | msgid "Installing" 465 | msgstr "" 466 | 467 | #: src/tool/installer.sh:192 src/tool/installer.sh:226 468 | #: src/tool/installer.sh:254 469 | msgid "tool" 470 | msgstr "" 471 | 472 | #: src/tool/installer.sh:198 473 | msgid "Using legacy policykit install" 474 | msgstr "" 475 | 476 | #: src/tool/installer.sh:199 src/tool/installer.sh:233 477 | msgid "policykit action" 478 | msgstr "" 479 | 480 | #: src/tool/installer.sh:204 src/tool/installer.sh:240 481 | #: src/tool/installer.sh:255 482 | msgid "policykit rule" 483 | msgstr "" 484 | 485 | #: src/tool/installer.sh:226 src/tool/installer.sh:233 486 | #: src/tool/installer.sh:240 src/tool/installer.sh:245 487 | msgid "Uninstalling" 488 | msgstr "" 489 | 490 | #: src/tool/installer.sh:227 src/tool/installer.sh:234 491 | #: src/tool/installer.sh:241 src/tool/installer.sh:248 492 | msgid "cannot remove" 493 | msgstr "" 494 | 495 | #: src/tool/installer.sh:250 496 | msgid "not installed at" 497 | msgstr "" 498 | 499 | #: src/ui/prefs.ui:63 500 | msgid "Install" 501 | msgstr "" 502 | 503 | #: src/ui/prefs.ui:70 504 | msgid "Install/Uninstall privileges for this user" 505 | msgstr "" 506 | 507 | #: src/ui/prefs.ui:71 508 | msgid "Setup a privileged script and give user access via polkit" 509 | msgstr "" 510 | 511 | #: src/ui/prefs.ui:82 512 | msgid "Install output" 513 | msgstr "" 514 | 515 | #: src/ui/prefs.ui:109 src/ui/prefs.ui:386 516 | msgid "Shutdown" 517 | msgstr "" 518 | 519 | #: src/ui/prefs.ui:113 src/ui/prefs.ui:243 520 | msgid "Timer Action" 521 | msgstr "" 522 | 523 | #: src/ui/prefs.ui:123 524 | msgid "Shown for reboot and shutdown if screensaver is inactive" 525 | msgstr "" 526 | 527 | #: src/ui/prefs.ui:134 528 | msgid "Toggle root shutdown with shutdown timer" 529 | msgstr "" 530 | 531 | #: src/ui/prefs.ui:135 532 | msgid "Runs extra 'shutdown -P/-r' command for shutdown or reboot" 533 | msgstr "" 534 | 535 | #: src/ui/prefs.ui:148 src/ui/prefs.ui:261 536 | msgid "Timer Input" 537 | msgstr "" 538 | 539 | #: src/ui/prefs.ui:151 src/ui/prefs.ui:264 540 | msgid "Reference start time" 541 | msgstr "" 542 | 543 | #: src/ui/prefs.ui:152 544 | msgid "Options: now, HH:MM" 545 | msgstr "" 546 | 547 | #: src/ui/prefs.ui:165 src/ui/prefs.ui:183 548 | msgid "Shutdown slider position (in %)" 549 | msgstr "" 550 | 551 | #: src/ui/prefs.ui:199 src/ui/prefs.ui:312 552 | msgid "Non-linear scaling or 0 to disable" 553 | msgstr "" 554 | 555 | #: src/ui/prefs.ui:218 556 | msgid "Maximum shutdown timer value (in minutes)" 557 | msgstr "" 558 | 559 | #: src/ui/prefs.ui:247 560 | msgid "Toggle wake with timer action" 561 | msgstr "" 562 | 563 | #: src/ui/prefs.ui:265 564 | msgid "Options: now, shutdown, HH:MM" 565 | msgstr "" 566 | 567 | #: src/ui/prefs.ui:278 src/ui/prefs.ui:296 568 | msgid "Wake slider position (in %)" 569 | msgstr "" 570 | 571 | #: src/ui/prefs.ui:331 572 | msgid "Maximum wake timer value (in minutes)" 573 | msgstr "" 574 | 575 | #: src/ui/prefs.ui:352 576 | msgid "Display" 577 | msgstr "" 578 | 579 | #: src/ui/prefs.ui:356 580 | msgid "General" 581 | msgstr "" 582 | 583 | #: src/ui/prefs.ui:389 584 | msgid "Show shutdown items" 585 | msgstr "" 586 | 587 | #: src/ui/prefs.ui:429 588 | msgid "Absolute shutdown time selection" 589 | msgstr "" 590 | 591 | #: src/ui/prefs.ui:446 592 | msgid "Show wake items" 593 | msgstr "" 594 | 595 | #: src/ui/prefs.ui:470 596 | msgid "Absolute wake time selection" 597 | msgstr "" 598 | -------------------------------------------------------------------------------- /scripts/.gitignore: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Deminder 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | *.json 5 | -------------------------------------------------------------------------------- /scripts/ranking.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # SPDX-FileCopyrightText: 2023 Deminder 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | import requests 7 | import os.path 8 | import json 9 | import sys 10 | import re 11 | 12 | 13 | def fetch_json(s, params): 14 | return s.get('https://extensions.gnome.org/extension-query/', params=params).json() 15 | 16 | 17 | def fetch_pages(sort='downloads', version='44'): 18 | p = {'page': 1} 19 | if sort is not None: 20 | p['sort'] = sort 21 | if version is not None: 22 | p['shell_version'] = version 23 | 24 | s = requests.Session() 25 | print('page', 0) 26 | first_page = fetch_json(s, p) 27 | extensions = first_page['extensions'] 28 | for n in range(1, first_page['numpages']): 29 | print('page', n) 30 | p['page'] = n + 1 31 | page = fetch_json(s, p) 32 | extensions += page['extensions'] 33 | 34 | return extensions 35 | 36 | 37 | if __name__ == '__main__': 38 | REFETCH = False 39 | if len(sys.argv) > 1: 40 | REFETCH = sys.argv[1] == 'fetch' 41 | FETCH_CACHE = 'ranking-downloads.json' 42 | if REFETCH or not os.path.exists(FETCH_CACHE): 43 | extensions = fetch_pages(version=None) 44 | with open(FETCH_CACHE, 'w') as f: 45 | json.dump(extensions, f) 46 | else: 47 | with open(FETCH_CACHE, 'r') as f: 48 | extensions = json.load(f) 49 | 50 | new_only = True 51 | downloads_dist = [] 52 | annotations = [] 53 | new_count = 0 54 | for i, e in enumerate(extensions): 55 | versions = e['shell_version_map'] 56 | is_new = any(int(v.split('.')[0]) >= 43 for v in versions.keys()) 57 | new_count += is_new 58 | if new_only and not is_new: 59 | continue 60 | dlds = int(e['downloads']) 61 | index = new_count if new_only else i 62 | highlight = e['creator'] == 'Deminder' 63 | if highlight: 64 | annotations.append([f'{e["name"]} ({index})', [index, dlds]]) 65 | downloads_dist.append(dlds) 66 | if highlight or re.match('.*(Shutdown|OSD).*', e['name'] + " " + e['description'], re.IGNORECASE | re.DOTALL): 67 | print(index, ('*' if highlight else '') + e['creator'], e['uuid'], 68 | '' if is_new else '[old]', dlds) 69 | print(new_count if new_only else len(extensions), '[last]') 70 | import numpy as np 71 | import matplotlib.pyplot as plt 72 | d = np.array(downloads_dist) 73 | # plt.plot(d[(d < 10 ** 6) * (d > 1000)]) 74 | plt.plot(d) 75 | plt.title('Extension Downloads') 76 | plt.yscale('log') 77 | for i, (s, xy) in enumerate(annotations): 78 | sign = 1 if i%2 == 0 else -3 79 | plt.annotate(s, xy=xy, xytext=(sign*20, sign*20), textcoords='offset points', arrowprops=dict(arrowstyle='->')) 80 | plt.grid(True) 81 | plt.show() 82 | -------------------------------------------------------------------------------- /src/dbus-interfaces/org.freedesktop.login1.Manager.xml: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | -------------------------------------------------------------------------------- /src/dbus-interfaces/org.gnome.SessionManager.xml: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/dbus-interfaces/org.gnome.Shell.Extensions.ShutdownTimer.xml: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/dbus-service/action.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Deminder 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | import Gio from 'gi://Gio'; 5 | import * as Control from './control.js'; 6 | import { proxyPromise } from '../modules/util.js'; 7 | import { pgettext as C_, gettext as _ } from '../modules/translation.js'; 8 | import { logDebug } from '../modules/util.js'; 9 | 10 | export const ACTIONS = { 11 | PowerOff: 0, 12 | Reboot: 1, 13 | Suspend: 2, 14 | SuspendThenHibernate: 3, 15 | Hibernate: 4, 16 | HybridSleep: 5, 17 | Halt: 6, 18 | }; 19 | 20 | export const WAKE_ACTIONS = { wake: 100, 'no-wake': 101 }; 21 | 22 | /** 23 | * Get supported actions. 24 | * In order to show an error when shutdown or reboot are not supported 25 | * they are always included here. */ 26 | export async function* supportedActions() { 27 | const actionDbus = new Action(); 28 | for await (const action of Object.keys(ACTIONS).map(async a => 29 | ['PowerOff', 'Reboot'].includes(a) || 30 | (await actionDbus.canShutdownAction(a)) 31 | ? a 32 | : null 33 | )) { 34 | if (action) { 35 | yield action; 36 | } 37 | } 38 | } 39 | 40 | export class UnsupportedActionError extends Error {} 41 | 42 | export class Action { 43 | #cancellable = new Gio.Cancellable(); 44 | #cookie = null; 45 | 46 | #loginProxy = proxyPromise( 47 | 'org.freedesktop.login1.Manager', 48 | Gio.DBus.system, 49 | 'org.freedesktop.login1', 50 | '/org/freedesktop/login1', 51 | this.#cancellable 52 | ); 53 | 54 | #screenSaverProxy = proxyPromise( 55 | 'org.gnome.ScreenSaver', 56 | Gio.DBus.session, 57 | 'org.gnome.ScreenSaver', 58 | '/org/gnome/ScreenSaver', 59 | this.#cancellable 60 | ); 61 | 62 | #sessionProxy = proxyPromise( 63 | 'org.gnome.SessionManager', 64 | Gio.DBus.session, 65 | 'org.gnome.SessionManager', 66 | '/org/gnome/SessionManager', 67 | this.#cancellable 68 | ); 69 | 70 | destroy() { 71 | if (this.#cancellable !== null) { 72 | this.#cancellable.cancel(); 73 | this.#cancellable = null; 74 | } 75 | } 76 | 77 | #poweroffOrReboot(action) { 78 | return [ACTIONS.PowerOff, ACTIONS.Reboot].includes(ACTIONS[action]); 79 | } 80 | 81 | /** 82 | * Perform the shutdown action. 83 | * 84 | * @param {string} action the shutdown action 85 | * @param {boolean} showEndSessionDialog show the end session dialog or directly shutdown 86 | * 87 | * @returns {Promise} resolves on action completion 88 | */ 89 | async shutdownAction(action, showEndSessionDialog) { 90 | if (!(action in ACTIONS)) 91 | throw new Error(`Unknown shutdown action: ${action}`); 92 | logDebug('[shutdownAction]', action); 93 | 94 | await this.uninhibitSuspend(); 95 | 96 | const screenSaverProxy = await this.#screenSaverProxy; 97 | const [screenSaverActive] = await screenSaverProxy.GetActiveAsync(); 98 | if ( 99 | showEndSessionDialog && 100 | !screenSaverActive && 101 | this.#poweroffOrReboot(action) 102 | ) { 103 | const sessionProxy = await this.#sessionProxy; 104 | if (action === 'PowerOff') { 105 | await sessionProxy.ShutdownAsync(); 106 | } else { 107 | await sessionProxy.RebootAsync(); 108 | } 109 | } else { 110 | const loginProxy = await this.#loginProxy; 111 | if (await this.canShutdownAction(action)) { 112 | await loginProxy[`${action}Async`](true); 113 | } else if (this.#poweroffOrReboot(action)) { 114 | await Control.shutdown('now', ACTIONS[action] === ACTIONS.Reboot); 115 | } else { 116 | throw new UnsupportedActionError(); 117 | } 118 | } 119 | } 120 | 121 | /** 122 | * Check if a shutdown action can be performed (without authentication). 123 | * 124 | * @returns {Promise} resolves to `true` if action can be performed, otherwise `false`. 125 | */ 126 | async canShutdownAction(action) { 127 | const loginProxy = await this.#loginProxy; 128 | if (!(action in ACTIONS)) 129 | throw new Error(`Unknown shutdown action: ${action}`); 130 | const [result] = await loginProxy[`Can${action}Async`](); 131 | return result === 'yes'; 132 | } 133 | 134 | /** 135 | * Schedule a wake after some minutes or cancel 136 | * 137 | * @param {boolean} wake 138 | * @param {number} minutes 139 | */ 140 | async wakeAction(wake, minutes) { 141 | if (wake) { 142 | await Control.wake(minutes); 143 | } else { 144 | await Control.wakeCancel(); 145 | } 146 | } 147 | 148 | async inhibitSuspend() { 149 | if (this.#cookie === null) { 150 | const sessionProxy = await this.#sessionProxy; 151 | const [cookie] = await sessionProxy.InhibitAsync( 152 | 'user', 153 | 0, 154 | 'Inhibit by Shutdown Timer (GNOME-Shell extension)', 155 | /* Suspend flag */ 4 156 | ); 157 | this.#cookie = cookie; 158 | } 159 | } 160 | 161 | async uninhibitSuspend() { 162 | if (this.#cookie !== null) { 163 | const sessionProxy = await this.#sessionProxy; 164 | await sessionProxy.UninhibitAsync(this.#cookie); 165 | this.#cookie = null; 166 | } 167 | } 168 | } 169 | 170 | /** 171 | * Get the translated action label 172 | * 173 | * @param action 174 | */ 175 | export function actionLabel(action) { 176 | return { 177 | SuspendThenHibernate: _('Suspend then Hibernate'), 178 | HybridSleep: _('Hybrid Sleep'), 179 | Hibernate: _('Hibernate'), 180 | Halt: _('Halt'), 181 | Suspend: _('Suspend'), 182 | PowerOff: _('Power Off'), 183 | Reboot: _('Restart'), 184 | wake: _('Wake'), 185 | 'no-wake': _('No Wake'), 186 | }[action]; 187 | } 188 | 189 | export function untilText(action) { 190 | return { 191 | SuspendThenHibernate: C_('untiltext', 'suspend and hibernate'), 192 | HybridSleep: C_('untiltext', 'hybrid sleep'), 193 | Hibernate: C_('untiltext', 'hibernate'), 194 | Halt: C_('untiltext', 'halt'), 195 | Suspend: C_('untiltext', 'suspend'), 196 | PowerOff: C_('untiltext', 'shutdown'), 197 | Reboot: C_('untiltext', 'reboot'), 198 | wake: C_('untiltext', 'wakeup'), 199 | }[action]; 200 | } 201 | 202 | export function mapLegacyAction(action) { 203 | return action in ACTIONS || ['wake', ''].includes(action) 204 | ? action 205 | : { 206 | poweroff: 'PowerOff', 207 | shutdown: 'PowerOff', 208 | reboot: 'Reboot', 209 | suspend: 'Suspend', 210 | }[action] ?? 211 | { 212 | p: 'PowerOff', 213 | r: 'Reboot', 214 | s: 'Suspend', 215 | h: 'SuspendThenHibernate', 216 | }[action[0].toLowerCase()]; 217 | } 218 | -------------------------------------------------------------------------------- /src/dbus-service/control.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Deminder 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | import GLib from 'gi://GLib'; 5 | import Gio from 'gi://Gio'; 6 | import { gettext as _ } from '../modules/translation.js'; 7 | 8 | import { logDebug } from '../modules/util.js'; 9 | 10 | function readLine(stream, cancellable) { 11 | return new Promise((resolve, reject) => { 12 | stream.read_line_async(0, cancellable, (s, res) => { 13 | try { 14 | const line = s.read_line_finish_utf8(res)[0]; 15 | 16 | if (line !== null) { 17 | resolve(line); 18 | } else { 19 | reject(new Error('No line was read!')); 20 | } 21 | } catch (e) { 22 | reject(e); 23 | } 24 | }); 25 | }); 26 | } 27 | 28 | function quoteEscape(str) { 29 | return str.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); 30 | } 31 | 32 | /** 33 | * Execute a command asynchronously and check the exit status. 34 | * 35 | * If given, @cancellable can be used to stop the process before it finishes. 36 | * 37 | * @param {string[] | string} argv - a list of string arguments or command line that will be parsed 38 | * @param {Gio.Cancellable} [cancellable] - optional cancellable object 39 | * @param {boolean} shell - run command as shell command 40 | * @param logFunc 41 | * @returns {Promise} - The process success 42 | */ 43 | export function execCheck( 44 | argv, 45 | cancellable = null, 46 | shell = true, 47 | logFunc = undefined 48 | ) { 49 | if (!shell && typeof argv === 'string') { 50 | argv = GLib.shell_parse_argv(argv)[1]; 51 | } 52 | 53 | const isRootProc = argv[0] && argv[0].endsWith('pkexec'); 54 | 55 | if (shell && Array.isArray(argv)) { 56 | argv = argv.map(c => `"${quoteEscape(c)}"`).join(' '); 57 | } 58 | let cancelId = 0; 59 | let proc = new Gio.Subprocess({ 60 | argv: (shell ? ['/bin/sh', '-c'] : []).concat(argv), 61 | flags: 62 | logFunc !== undefined 63 | ? Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE 64 | : Gio.SubprocessFlags.NONE, 65 | }); 66 | proc.init(cancellable); 67 | 68 | if (cancellable instanceof Gio.Cancellable) { 69 | cancelId = cancellable.connect(() => { 70 | if (logFunc !== undefined) { 71 | if (isRootProc) { 72 | logFunc(`# ${_('Can not cancel root process!')}`); 73 | } else { 74 | logFunc(`[${_('CANCEL')}]`); 75 | } 76 | } 77 | proc.force_exit(); 78 | }); 79 | } 80 | let stdoutStream = null; 81 | let stderrStream = null; 82 | let readLineCancellable = null; 83 | 84 | if (logFunc !== undefined) { 85 | readLineCancellable = new Gio.Cancellable(); 86 | stdoutStream = new Gio.DataInputStream({ 87 | base_stream: proc.get_stdout_pipe(), 88 | close_base_stream: true, 89 | }); 90 | 91 | stderrStream = new Gio.DataInputStream({ 92 | base_stream: proc.get_stderr_pipe(), 93 | close_base_stream: true, 94 | }); 95 | const readNextLine = async (stream, prefix) => { 96 | try { 97 | const line = await readLine(stream, readLineCancellable); 98 | logFunc(prefix + line); 99 | logDebug(line); 100 | await readNextLine(stream, prefix); 101 | } catch { 102 | if (!stream.is_closed()) { 103 | stream.close_async(0, null, (s, sRes) => { 104 | try { 105 | s.close_finish(sRes); 106 | } catch (e) { 107 | logDebug(`[StreamCloseError] ${e}`); 108 | } 109 | }); 110 | } 111 | } 112 | }; 113 | // read stdout and stderr asynchronously 114 | readNextLine(stdoutStream, ''); 115 | readNextLine(stderrStream, '# '); 116 | } 117 | 118 | return new Promise((resolve, reject) => { 119 | proc.wait_check_async(null, (p, res) => { 120 | try { 121 | const success = p.wait_check_finish(res); 122 | if (!success) { 123 | let status = p.get_exit_status(); 124 | 125 | throw new Gio.IOErrorEnum({ 126 | code: Gio.io_error_from_errno(status), 127 | message: GLib.strerror(status), 128 | }); 129 | } 130 | 131 | resolve(); 132 | } catch (e) { 133 | reject(e); 134 | } finally { 135 | if (readLineCancellable) readLineCancellable.cancel(); 136 | readLineCancellable = null; 137 | if (cancelId > 0) cancellable.disconnect(cancelId); 138 | } 139 | }); 140 | }); 141 | } 142 | 143 | export function sleepUntilDeadline(deadlineSeconds, cancellable) { 144 | return new Promise((resolve, reject) => { 145 | const secondsLeft = () => 146 | deadlineSeconds - GLib.DateTime.new_now_utc().to_unix(); 147 | 148 | let timeoutId = 0; 149 | let handlerId = cancellable.connect(() => { 150 | handlerId = 0; 151 | if (timeoutId) { 152 | clearTimeout(timeoutId); 153 | } 154 | reject(new Error('Sleep until deadline cancelled!')); 155 | }); 156 | 157 | const continueSleep = () => { 158 | timeoutId = setTimeout(() => { 159 | timeoutId = 0; 160 | if (secondsLeft() > 0) { 161 | continueSleep(); 162 | } else { 163 | if (handlerId) { 164 | cancellable.disconnect(handlerId); 165 | } 166 | resolve(); 167 | } 168 | }, 1000); 169 | }; 170 | continueSleep(); 171 | }); 172 | } 173 | 174 | export function installedScriptPath() { 175 | for (const name of [ 176 | 'shutdowntimerctl', 177 | `shutdowntimerctl-${GLib.get_user_name()}`, 178 | ]) { 179 | const standard = GLib.find_program_in_path(name); 180 | if (standard !== null) { 181 | return standard; 182 | } 183 | for (const bindir of ['/usr/local/bin/', '/usr/bin/']) { 184 | const path = bindir + name; 185 | logDebug(`Looking for: ${path}`); 186 | if (Gio.File.new_for_path(path).query_exists(null)) { 187 | return path; 188 | } 189 | } 190 | } 191 | return null; 192 | } 193 | 194 | function execControlScript(args, noScriptArgs) { 195 | const installedScript = installedScriptPath(); 196 | if (installedScript !== null) { 197 | return execCheck(['pkexec', installedScript].concat(args), null, false); 198 | } 199 | if (noScriptArgs === undefined) { 200 | throw new Error(_('Privileged script installation required!')); 201 | } 202 | return execCheck(noScriptArgs, null, false); 203 | } 204 | 205 | export function shutdown(minutes, reboot = false) { 206 | logDebug(`[root-shutdown] ${minutes} minutes, reboot: ${reboot}`); 207 | return execControlScript( 208 | [reboot ? 'reboot' : 'shutdown', `${minutes}`], 209 | ['shutdown', reboot ? '-r' : '-P', `${minutes}`] 210 | ); 211 | } 212 | 213 | function shutdownCancel() { 214 | logDebug('[root-shutdown] cancel'); 215 | return execControlScript(['shutdown-cancel'], ['shutdown', '-c']); 216 | } 217 | 218 | export function wake(minutes) { 219 | return execControlScript(['wake', `${minutes}`]); 220 | } 221 | 222 | export function wakeCancel() { 223 | return execControlScript(['wake-cancel']); 224 | } 225 | 226 | export async function stopRootModeProtection(info) { 227 | if (['PowerOff', 'Reboot'].includes(info.mode)) { 228 | await shutdownCancel(); 229 | } 230 | } 231 | export async function startRootModeProtection(info) { 232 | if (['PowerOff', 'Reboot'].includes(info.mode)) { 233 | await shutdown(Math.max(0, info.minutes) + 1, info.mode === 'Reboot'); 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/dbus-service/shutdown-timer-dbus.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Deminder 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | import Gio from 'gi://Gio'; 5 | import GLib from 'gi://GLib'; 6 | 7 | import { loadInterfaceXML, logDebug } from '../modules/util.js'; 8 | import { Timer } from './timer.js'; 9 | 10 | export const ShutdownTimerName = 'org.gnome.Shell.Extensions.ShutdownTimer'; 11 | export const ShutdownTimerObjectPath = 12 | '/org/gnome/Shell/Extensions/ShutdownTimer'; 13 | export const ShutdownTimerIface = await loadInterfaceXML(ShutdownTimerName); 14 | 15 | export class ShutdownTimerDBus { 16 | constructor({ settings }) { 17 | this._dbusImpl = Gio.DBusExportedObject.wrapJSObject( 18 | ShutdownTimerIface, 19 | this 20 | ); 21 | this._dbusImpl.export(Gio.DBus.session, ShutdownTimerObjectPath); 22 | 23 | this._timer = new Timer({ settings }); 24 | this._timer.connect('message', (_, msg) => { 25 | logDebug('[shutdown-timer-dbus] msg', msg); 26 | this._dbusImpl.emit_signal('OnMessage', new GLib.Variant('(s)', [msg])); 27 | }); 28 | this._timer.connect('change', () => { 29 | logDebug('[shutdown-timer-dbus] state', this._timer.state); 30 | this._dbusImpl.emit_signal( 31 | 'OnStateChange', 32 | new GLib.Variant('(s)', [this._timer.state]) 33 | ); 34 | }); 35 | this._timer.connect('change-external', () => { 36 | this._dbusImpl.emit_signal('OnExternalChange', null); 37 | }); 38 | } 39 | 40 | async ScheduleShutdownAsync(parameters, invocation) { 41 | const [shutdown, action] = parameters; 42 | logDebug( 43 | '[sdt-dbus] [ScheduleShutdownAsync] shutdown', 44 | shutdown, 45 | 'action', 46 | action 47 | ); 48 | await this._timer.toggleShutdown(shutdown, action); 49 | invocation.return_value(null); 50 | } 51 | 52 | async ScheduleWakeAsync(parameters, invocation) { 53 | const [wake] = parameters; 54 | logDebug('[sdt-dbus] [ScheduleWakeAsync] wake', wake); 55 | await this._timer.toggleWake(wake); 56 | invocation.return_value(null); 57 | } 58 | 59 | GetStateAsync(_, invocation) { 60 | logDebug('[sdt-dbus] [GetStateAsync]'); 61 | invocation.return_value(new GLib.Variant('(s)', [this._timer.state])); 62 | return Promise.resolve(); 63 | } 64 | 65 | destroy() { 66 | logDebug('[sdt-dbus] destroy'); 67 | this._dbusImpl.unexport(); 68 | this._dbusImpl = null; 69 | this._timer.destroy(); 70 | this._timer = null; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/dbus-service/timer.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Deminder 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | import GLib from 'gi://GLib'; 5 | import Gio from 'gi://Gio'; 6 | import { 7 | gettext as _, 8 | ngettext as _n, 9 | pgettext as C_, 10 | } from '../modules/translation.js'; 11 | 12 | import { logDebug, throttleTimeout } from '../modules/util.js'; 13 | import * as Control from './control.js'; 14 | import { 15 | longDurationString, 16 | ScheduleInfo, 17 | getShutdownScheduleFromSettings, 18 | getSliderMinutesFromSettings, 19 | } from '../modules/schedule-info.js'; 20 | import { 21 | actionLabel, 22 | Action, 23 | mapLegacyAction, 24 | UnsupportedActionError, 25 | } from './action.js'; 26 | 27 | const Signals = imports.signals; 28 | 29 | export class Timer { 30 | _timerCancellable = null; 31 | _updateScheduleCancel = null; 32 | _action = new Action(); 33 | info = { 34 | externalShutdown: new ScheduleInfo({ 35 | external: true, 36 | }), 37 | externalWake: new ScheduleInfo({ 38 | external: false, 39 | mode: 'wake', 40 | }), 41 | internalShutdown: new ScheduleInfo({ mode: 'PowerOff' }), 42 | }; 43 | 44 | constructor({ settings }) { 45 | this._settings = settings; 46 | 47 | const [updateScheduleThrottled, updateScheduleCancel] = throttleTimeout( 48 | () => this.updateSchedule(), 49 | 20 50 | ); 51 | this._updateScheduleCancel = updateScheduleCancel; 52 | this._settingsIds = [ 53 | 'root-mode-value', 54 | 'shutdown-timestamp-value', 55 | 'shutdown-mode-value', 56 | ].map(settingName => 57 | settings.connect(`changed::${settingName}`, updateScheduleThrottled) 58 | ); 59 | 60 | this.updateSchedule(); 61 | } 62 | 63 | destroy() { 64 | if (this._settings === null) { 65 | throw new Error('should not destroy twice'); 66 | } 67 | // Disconnect settings 68 | this._settingsIds.forEach(id => this._settings.disconnect(id)); 69 | this._settingsIds = []; 70 | this._settings = null; 71 | 72 | // Cancel schedule updates 73 | if (this._updateScheduleCancel !== null) { 74 | this._updateScheduleCancel(); 75 | this._updateScheduleCancel = null; 76 | } 77 | 78 | // Cancel internal timer 79 | if (this._timerCancellable !== null) { 80 | this._timerCancellable.cancel(); 81 | this._timerCancellable = null; 82 | } 83 | 84 | // External schedules (for 'shutdown' and 'wake') are not stopped 85 | } 86 | 87 | updateSchedule() { 88 | const oldInternal = this.info.internalShutdown; 89 | const internal = getShutdownScheduleFromSettings(this._settings); 90 | this.info.internalShutdown = internal; 91 | logDebug( 92 | `[updateSchedule] internal schedule: ${internal.label} (old internal schedule: ${oldInternal.label})` 93 | ); 94 | this._updateRootModeProtection(oldInternal); 95 | if ( 96 | internal.mode !== oldInternal.mode || 97 | internal.deadline !== oldInternal.deadline 98 | ) { 99 | this.emit('change'); 100 | if (internal.scheduled) { 101 | if (internal.minutes > 0) { 102 | // Show schedule info 103 | this.emit( 104 | 'message', 105 | C_('StartSchedulePopup', '%s in %s').format( 106 | actionLabel(internal.mode), 107 | longDurationString( 108 | internal.minutes, 109 | h => _n('%s hour', '%s hours', h), 110 | m => _n('%s minute', '%s minutes', m) 111 | ) 112 | ) 113 | ); 114 | } else { 115 | logDebug(`[updateSchedule] hidden message for '< 1 minute' schedule`); 116 | } 117 | if (this._timerCancellable !== null) { 118 | this._timerCancellable.cancel(); 119 | this._timerCancellable = null; 120 | } 121 | this.executeActionDelayed() 122 | .then(() => { 123 | logDebug('[executeActionDelayed] done'); 124 | }) 125 | .catch(err => { 126 | console.error('executeActionDelayed', err); 127 | }); 128 | } else { 129 | if (this._timerCancellable !== null) { 130 | this._timerCancellable.cancel(); 131 | this._timerCancellable = null; 132 | } 133 | if (oldInternal.scheduled) { 134 | this.emit('message', _('Shutdown Timer stopped')); 135 | } 136 | } 137 | } 138 | } 139 | 140 | async executeAction() { 141 | const internal = this.info.internalShutdown; 142 | if (!internal.scheduled) { 143 | logDebug(`Refusing to exectute non scheduled action! '${internal.mode}'`); 144 | return; 145 | } 146 | logDebug(`Running '${internal.mode}' timer action...`); 147 | try { 148 | this.emit('change'); 149 | // Refresh root mode protection 150 | await Promise.all([ 151 | this._updateRootModeProtection(), 152 | // Do shutdown 153 | this._action.shutdownAction( 154 | internal.mode, 155 | this._settings.get_boolean('show-end-session-dialog-value') 156 | ), 157 | ]); 158 | } catch (err) { 159 | if (/* destroyed */ this._settings === null) { 160 | throw err; 161 | } 162 | const newInternal = this.info.internalShutdown; 163 | if (newInternal.scheduled && newInternal.secondsLeft > 0) { 164 | logDebug('[timer] Replaced by new schedule.'); 165 | return; 166 | } 167 | if (err instanceof UnsupportedActionError) { 168 | this.emit( 169 | 'message', 170 | _('%s is not supported!').format(actionLabel(internal.mode)) 171 | ); 172 | } 173 | } 174 | await this.toggleShutdown(false); 175 | } 176 | 177 | async executeActionDelayed() { 178 | const internal = this.info.internalShutdown; 179 | const secs = internal.secondsLeft; 180 | if (secs > 0) { 181 | logDebug(`Started delayed action: ${internal.minutes}min remaining`); 182 | try { 183 | this._timerCancellable = new Gio.Cancellable(); 184 | await Control.sleepUntilDeadline( 185 | internal.deadline, 186 | this._timerCancellable 187 | ); 188 | this._timerCancellable = null; 189 | } catch { 190 | logDebug(`Canceled delayed action: ${internal.minutes}min remaining`); 191 | return; 192 | } 193 | } 194 | await this.executeAction(); 195 | } 196 | 197 | async toggleWake(wake) { 198 | try { 199 | logDebug('[toggleWake] wake', wake); 200 | await this._action.wakeAction( 201 | wake, 202 | getSliderMinutesFromSettings(this._settings, 'wake') 203 | ); 204 | this.emit('change-external'); 205 | } catch (err) { 206 | this.emit( 207 | 'message', 208 | C_('Error', '%s\n%s').format(_('Wake action failed!'), err) 209 | ); 210 | this._settings.set_int('shutdown-timestamp-value', -1); 211 | } 212 | } 213 | 214 | async toggleShutdown(shutdown, legacyAction) { 215 | // Update shutdown action 216 | const action = mapLegacyAction(legacyAction); 217 | if (action === undefined) { 218 | this.emit( 219 | 'message', 220 | _('Unknown shutdown action: "%s"!').format(legacyAction) 221 | ); 222 | return; 223 | } 224 | logDebug('[toggleShutdown] shutdown', shutdown, 'action', action); 225 | if (action !== '') { 226 | this._settings.set_string('shutdown-mode-value', action); 227 | } 228 | 229 | // Update shutdown timestamp 230 | this._settings.set_int( 231 | 'shutdown-timestamp-value', 232 | shutdown 233 | ? GLib.DateTime.new_now_utc().to_unix() + 234 | Math.max( 235 | 1, 236 | getSliderMinutesFromSettings(this._settings, 'shutdown') * 60 237 | ) 238 | : -1 239 | ); 240 | if (shutdown) { 241 | await this._action.inhibitSuspend(); 242 | } else { 243 | await this._action.uninhibitSuspend(); 244 | } 245 | if (this._settings.get_boolean('auto-wake-value')) { 246 | await this.toggleWake(shutdown); 247 | } 248 | } 249 | 250 | get state() { 251 | return this.info.internalShutdown.scheduled 252 | ? this.info.internalShutdown.secondsLeft > 0 253 | ? 'active' 254 | : 'action' 255 | : 'inactive'; 256 | } 257 | 258 | /** 259 | * Ensure that shutdown/reboot is executed even if the Timer fails by running 260 | * the `shutdown` command delayed by 1 minute. 261 | */ 262 | async _updateRootModeProtection(oldInternal) { 263 | if (this._settings.get_boolean('root-mode-value')) { 264 | const internal = this.info.internalShutdown; 265 | logDebug('[updateRootModeProtection] mode ', internal.mode); 266 | try { 267 | if (oldInternal?.scheduled && oldInternal.mode !== internal.mode) { 268 | await Control.stopRootModeProtection(oldInternal); 269 | } 270 | if (internal.scheduled) { 271 | await Control.startRootModeProtection(internal); 272 | } else { 273 | await Control.stopRootModeProtection(internal); 274 | } 275 | } catch (err) { 276 | this.emit( 277 | 'message', 278 | C_('Error', '%s\n%s').format(_('Root mode protection failed!'), err) 279 | ); 280 | console.error('[updateRootModeProtection]', err); 281 | } 282 | this.emit('change-external'); 283 | } 284 | } 285 | } 286 | Signals.addSignalMethods(Timer.prototype); 287 | -------------------------------------------------------------------------------- /src/extension.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Deminder , D. Neumann 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js'; 5 | 6 | import { currentSessionMode } from './modules/session-mode-aware.js'; 7 | import { ShutdownTimerIndicator } from './modules/menu-item.js'; 8 | import { logDebug } from './modules/util.js'; 9 | import { addExternalIndicator } from './modules/quicksettings.js'; 10 | import { InjectionTracker } from './modules/injection.js'; 11 | import { ShutdownTimerDBus } from './dbus-service/shutdown-timer-dbus.js'; 12 | 13 | export default class ShutdownTimer extends Extension { 14 | #sdt = null; 15 | #disableTimestamp = 0; 16 | 17 | enable() { 18 | const settings = this.getSettings(); 19 | 20 | if (!this.#disableTimestamp || Date.now() > this.#disableTimestamp + 100) { 21 | logDebug('[ENABLE] Clear shutdown schedule'); 22 | settings.set_int('shutdown-timestamp-value', -1); 23 | } 24 | 25 | logDebug(`[ENABLE] '${currentSessionMode()}'`); 26 | this.#sdt = new ShutdownTimerDBus({ settings }); 27 | 28 | const indicator = new ShutdownTimerIndicator({ 29 | path: this.path, 30 | settings, 31 | }); 32 | indicator.connect('open-preferences', () => this.openPreferences()); 33 | 34 | // Add ShutdownTimer indicator to quicksettings menu 35 | this._tracker = new InjectionTracker(); 36 | addExternalIndicator(this._tracker, indicator); 37 | 38 | this._indicator = indicator; 39 | 40 | logDebug(`[ENABLE-DONE] '${currentSessionMode()}'`); 41 | } 42 | 43 | disable() { 44 | // Extension should not be disabled during unlock-dialog`: 45 | // When the `unlock-dialog` is active, the quicksettings indicator and item 46 | // should remain visible. 47 | logDebug(`[DISABLE] '${currentSessionMode()}'`); 48 | this._tracker.clearAll(); 49 | this._tracker = null; 50 | 51 | this._indicator.destroy(); 52 | this._indicator = null; 53 | 54 | this.#sdt.destroy(); 55 | this.#sdt = null; 56 | this.#disableTimestamp = Date.now(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/icons/shutdown-timer-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/icons/shutdown-timer-symbolic.svg.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2023 Deminder 2 | 3 | SPDX-License-Identifier: GPL-3.0-or-later 4 | -------------------------------------------------------------------------------- /src/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "shell-version": ["45", "46", "47", "48"], 3 | "uuid": "ShutdownTimer@deminder", 4 | "name": "Shutdown Timer", 5 | "gettext-domain": "ShutdownTimer", 6 | "settings-schema": "org.gnome.shell.extensions.shutdowntimer-deminder", 7 | "description": "Shutdown/reboot/suspend the device after a specific time or wake with a rtc alarm.\n\nThe screen-saver will not interrupt the timer. A privileged control script may be installed to control shutdown and rtcwake as user.", 8 | "url": "https://github.com/Deminder/ShutdownTimer", 9 | "session-modes": ["user", "unlock-dialog"], 10 | "version": 51 11 | } 12 | -------------------------------------------------------------------------------- /src/metadata.json.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2023 Deminder 2 | 3 | SPDX-License-Identifier: GPL-3.0-or-later 4 | -------------------------------------------------------------------------------- /src/modules/info-fetcher.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Deminder 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | import Gio from 'gi://Gio'; 5 | import GLib from 'gi://GLib'; 6 | import * as Signals from 'resource:///org/gnome/shell/misc/signals.js'; 7 | 8 | import { throttleTimeout, logDebug, readFileAsync } from './util.js'; 9 | 10 | export class InfoFetcher extends Signals.EventEmitter { 11 | constructor() { 12 | super(); 13 | this._intervalId = null; 14 | this._tickPromise = null; 15 | this._shutdownInfo = {}; 16 | this._wakeInfo = {}; 17 | this._rtc = 'rtc0'; 18 | this._cancellable = new Gio.Cancellable(); 19 | [this.refresh, this._refreshCancel] = throttleTimeout( 20 | this._refresh.bind(this), 21 | 300 22 | ); 23 | } 24 | 25 | _refresh() { 26 | this._refreshCancel(); 27 | this._clearInterval(); 28 | logDebug('Extra info refresh...'); 29 | this._intervalId = setInterval(this.tick.bind(this), 5000); 30 | this.tick(); 31 | } 32 | 33 | _clearInterval() { 34 | if (this._intervalId !== null) { 35 | clearInterval(this._intervalId); 36 | this._intervalId = null; 37 | } 38 | } 39 | 40 | tick() { 41 | if (this._tickPromise === null) { 42 | this._tickPromise = Promise.all([ 43 | this._fetchShutdownInfo(), 44 | this._fetchWakeInfo(this._rtc), 45 | ]).then(([shutdown, wake]) => { 46 | this._tickPromise = null; 47 | this._shutdownInfo = shutdown; 48 | this._wakeInfo = wake; 49 | this.emit('changed'); 50 | }); 51 | } 52 | } 53 | 54 | _readFile(path) { 55 | return readFileAsync(path, this._cancellable); 56 | } 57 | 58 | async _isWakeInfoLocal() { 59 | const content = await this._readFile('/etc/adjtime').catch(() => ''); 60 | return content.trim().toLowerCase().endsWith('local'); 61 | } 62 | 63 | async _fetchWakeInfo(rtc) { 64 | const content = await this._readFile( 65 | `/sys/class/rtc/${rtc}/wakealarm` 66 | ).catch(() => ''); 67 | let timestamp = content !== '' ? parseInt(content) : -1; 68 | if (timestamp > -1 && (await this._isWakeInfoLocal())) { 69 | const dt = GLib.DateTime.new_from_unix_local(timestamp); 70 | timestamp = dt.to_unix() - dt.get_utc_offset() / 1000000; 71 | dt.unref(); 72 | } 73 | return { deadline: timestamp }; 74 | } 75 | 76 | async _fetchShutdownInfo() { 77 | const content = await this._readFile( 78 | '/run/systemd/shutdown/scheduled' 79 | ).catch(() => ''); 80 | if (content === '') { 81 | return { deadline: -1 }; 82 | } 83 | // content: schedule unix-timestamp (micro-seconds), warn-all, shutdown-mode 84 | const [usec, _, mode] = content.split('\n').map(l => l.split('=')[1]); 85 | return { 86 | mode, 87 | deadline: parseInt(usec) / 1000000, 88 | }; 89 | } 90 | 91 | get shutdownInfo() { 92 | return this._shutdownInfo; 93 | } 94 | 95 | get wakeInfo() { 96 | return this._wakeInfo; 97 | } 98 | 99 | destroy() { 100 | this.disconnectAll(); 101 | this._refreshCancel(); 102 | this._clearInterval(); 103 | if (this._cancellable !== null) { 104 | this._cancellable.cancel(); 105 | this._cancellable = null; 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/modules/injection.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Deminder 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | import { logDebug } from './util.js'; 4 | 5 | /** 6 | * The InjectionTracker helps revert object property injections 'out of order' 7 | * by keeping track of the injection history. 8 | * This is useful when overriding the same method from mulitple extensions, 9 | * for instance, `Main.panel.statusArea.quickSettings._addItems`. 10 | * 11 | * The InjectionManager does not work when multiple extensions override the same method. 12 | * Its `restoreMethod` only restores correctly if it is called in reverse order to `overrideMethod`. 13 | * 14 | */ 15 | export class InjectionTracker { 16 | localInjections = []; 17 | 18 | /** 19 | * Inject a property into an object. 20 | * 21 | * To revert the injection call `injection.clear()`. 22 | * 23 | * @param {object} obj the target object 24 | * @param {string} prop the property name 25 | * @param {any} value the new property value 26 | * @param {boolean} isPropertyDescriptor whether the value is a property descriptor 27 | * 28 | * @returns {object} an injection object 29 | * provding `injection.original` and `injection.previous` property values 30 | */ 31 | injectProperty(obj, prop, value, isPropertyDescriptor) { 32 | let propertyDescriptor = Object.getOwnPropertyDescriptor(obj, prop); 33 | if (!propertyDescriptor) { 34 | if (prop in obj) { 35 | propertyDescriptor = { 36 | value: obj[prop], 37 | writable: true, 38 | configurable: true, 39 | }; 40 | } else { 41 | throw new Error( 42 | `Injection target object does not have a '${prop}' property!` 43 | ); 44 | } 45 | } 46 | // Keep history; mutated by each injection 47 | const histories = obj.__injectionHistories ?? {}; 48 | const history = histories[prop] ?? []; 49 | // Push old property descriptor to history 50 | history.push(propertyDescriptor); 51 | histories[prop] = history; 52 | obj.__injectionHistories = histories; 53 | 54 | const injectionId = history.length; 55 | logDebug('[new] injectionid', injectionId); 56 | 57 | // Override value 58 | Object.defineProperty( 59 | obj, 60 | prop, 61 | isPropertyDescriptor 62 | ? value 63 | : { value, writable: false, configurable: true } 64 | ); 65 | 66 | const localInjectionId = this.localInjections.length; 67 | const injection = createInjection(obj, prop, history, injectionId, () => { 68 | // Remove from local injections 69 | this.localInjections[localInjectionId] = undefined; 70 | const pruneIndex = 71 | this.localInjections.length - 72 | 1 - 73 | [...this.localInjections].reverse().findIndex(inj => inj !== undefined); 74 | if (pruneIndex === this.localInjections.length) { 75 | logDebug('[local-cleanclear]'); 76 | this.localInjections = []; 77 | } else { 78 | logDebug('[local-nocleanclear]'); 79 | this.localInjections = this.localInjections.slice(0, pruneIndex); 80 | } 81 | }); 82 | this.localInjections.push(injection); 83 | return injection; 84 | } 85 | 86 | /** 87 | * Clear all injections made by this instance. 88 | */ 89 | clearAll() { 90 | this.localInjections 91 | .filter(inj => inj !== undefined) 92 | .forEach(inj => inj.clear()); 93 | } 94 | } 95 | 96 | function createInjection(obj, prop, history, injectionId, clearHook) { 97 | let reverted = false; 98 | const readDescriptorValue = descriptor => 99 | 'get' in descriptor ? descriptor.get.call(obj) : descriptor.value; 100 | return { 101 | /** 102 | * Read the original property value before any injections. 103 | */ 104 | get original() { 105 | // Using h instead history to remain valid after `clear()` 106 | const h = obj.__injectionHistories?.[prop]; 107 | return readDescriptorValue( 108 | h?.length ? h[0] : Object.getOwnPropertyDescriptor(obj, prop) 109 | ); 110 | }, 111 | 112 | /** 113 | * Read the previous property value from the injection history. 114 | * 115 | * Valid after `clear()` as long as no new injection occurs. 116 | */ 117 | get previous() { 118 | return history.length 119 | ? readDescriptorValue( 120 | injectionId > history.length 121 | ? Object.getOwnPropertyDescriptor(obj, prop) 122 | : popPropertyDescriptorFromHistory( 123 | // previous history 124 | history.slice(0, injectionId) 125 | ) 126 | ) 127 | : this.original; 128 | }, 129 | 130 | /** 131 | * Clear the injection from the injection history. 132 | */ 133 | clear() { 134 | if (!reverted) { 135 | reverted = true; 136 | clearHook(); 137 | 138 | // Remove from global injections 139 | if (injectionId >= history.length) { 140 | logDebug( 141 | '[remclear] injectionID', 142 | injectionId, 143 | 'historylen', 144 | history.length 145 | ); 146 | // Restore property of obj 147 | Object.defineProperty( 148 | obj, 149 | prop, 150 | popPropertyDescriptorFromHistory(history) 151 | ); 152 | // Cleanup empty history 153 | if (history.length === 0) { 154 | logDebug('[cleanclear]'); 155 | delete obj.__injectionHistories[prop]; 156 | 157 | // Cleanup empty history store 158 | if (Object.keys(obj.__injectionHistories).length === 0) { 159 | delete obj.__injectionHistories; 160 | } 161 | } else { 162 | logDebug('[nocleanclear] history.length', history.length); 163 | } 164 | } else { 165 | logDebug( 166 | '[setclear] injectionID', 167 | injectionId, 168 | 'historylen', 169 | history.length 170 | ); 171 | // Clear injection from history 172 | history[injectionId] = undefined; 173 | } 174 | } 175 | }, 176 | 177 | /** 178 | * Count of previous injections. Is 1 if there is only this injection. 179 | */ 180 | get count() { 181 | return history.slice(0, injectionId).filter(i => i !== undefined).length; 182 | }, 183 | }; 184 | } 185 | 186 | function popPropertyDescriptorFromHistory(history) { 187 | let propertyDescriptor; 188 | do { 189 | propertyDescriptor = history.pop(); 190 | } while (history.length && propertyDescriptor === undefined); 191 | return propertyDescriptor; 192 | } 193 | -------------------------------------------------------------------------------- /src/modules/install.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Deminder 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | import GLib from 'gi://GLib'; 5 | import Gio from 'gi://Gio'; 6 | import { gettext as _ } from './translation.js'; 7 | 8 | import { execCheck, installedScriptPath } from '../dbus-service/control.js'; 9 | import { logDebug } from './util.js'; 10 | 11 | export class Install { 12 | destroy() { 13 | if (this.installCancel !== undefined) { 14 | this.installCancel.cancel(); 15 | } 16 | this.installCancel = undefined; 17 | } 18 | 19 | /** 20 | * @param installerScriptPath 21 | * @param action 22 | * @param logInstall 23 | */ 24 | async installAction(installerScriptPath, action, logInstall) { 25 | const label = this.actionLabel(action); 26 | if (this.installCancel !== undefined) { 27 | logDebug(`Trigger cancel install. ${label}`); 28 | this.installCancel.cancel(); 29 | } else { 30 | logDebug(`Trigger ${action} action.`); 31 | this.installCancel = new Gio.Cancellable(); 32 | logInstall(`[${_('START')} ${label}]`); 33 | try { 34 | const user = GLib.get_user_name(); 35 | logDebug(`? installer.sh --tool-user ${user} ${action}`); 36 | await execCheck( 37 | ['pkexec', installerScriptPath, '--tool-user', user, action], 38 | this.installCancel, 39 | false, 40 | logInstall 41 | ); 42 | logInstall(`[${_('END')} ${label}]`); 43 | } catch (err) { 44 | logInstall(`[${_('FAIL')} ${label}]\n# ${err}`); 45 | console.error(err, 'InstallError'); 46 | } finally { 47 | this.installCancel = undefined; 48 | } 49 | } 50 | } 51 | 52 | checkInstalled() { 53 | const scriptPath = installedScriptPath(); 54 | const isScriptInstalled = scriptPath !== null; 55 | if (isScriptInstalled) { 56 | logDebug(`Existing installation at: ${scriptPath}`); 57 | } 58 | return isScriptInstalled; 59 | } 60 | 61 | actionLabel(action) { 62 | return { install: _('install'), uninstall: _('uninstall') }[action]; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/modules/quicksettings.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Deminder 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 5 | 6 | /** 7 | * Add an external indicator after an existing indicator and above an existing indicator item. 8 | */ 9 | export function addExternalIndicator( 10 | tracker, 11 | indicator, 12 | after = '_system', 13 | above = '_backgroundApps', 14 | colSpan 15 | ) { 16 | const qs = Main.panel.statusArea.quickSettings; 17 | const indicatorItems = indicator.quickSettingsItems; 18 | const aboveIndicator = qs[above]; 19 | if (aboveIndicator === undefined) { 20 | if ('_addItems' in qs) { 21 | // 45.beta.1: _setupIndicators is not done 22 | const injection = tracker.injectProperty( 23 | qs, 24 | '_addItems', 25 | (items, col) => { 26 | if (Object.is(items, qs[above].quickSettingsItems)) { 27 | injection.clear(); 28 | // Insert after: insert_child_above(a,b): inserts 'a' after 'b' 29 | qs._indicators.insert_child_above(indicator, qs[after]); 30 | // Insert above 31 | injection.original.call(qs, indicatorItems, colSpan); 32 | } 33 | injection.previous.call(qs, items, col); 34 | } 35 | ); 36 | } else { 37 | // 45.rc: _setupIndicators is not done 38 | const qsm = qs.menu; 39 | const injection = tracker.injectProperty( 40 | qsm, 41 | '_completeAddItem', 42 | (item, col) => { 43 | const firstAboveItem = qs[above]?.quickSettingsItems.at(-1); 44 | if (Object.is(firstAboveItem, item)) { 45 | injection.clear(); 46 | // Insert after: insert_child_above(a,b): inserts 'a' after 'b' 47 | qs._indicators.insert_child_above(indicator, qs[after]); 48 | // Insert above 49 | indicatorItems.forEach(newItem => 50 | qsm.insertItemBefore(newItem, item, colSpan) 51 | ); 52 | } 53 | injection.previous.call(qsm, item, col); 54 | } 55 | ); 56 | } 57 | } else if ('_addItems' in qs) { 58 | // 45.beta.1: _setupIndicators is done 59 | // Insert after: insert_child_above(a,b): inserts 'a' after 'b' 60 | qs._indicators.insert_child_above(indicator, qs[after]); 61 | // Insert above 62 | qs._addItems(indicatorItems, colSpan); 63 | const firstAboveItem = aboveIndicator.quickSettingsItems.at(-1); 64 | indicatorItems.forEach(item => { 65 | qs.menu._grid.remove_child(item); 66 | qs.menu._grid.insert_child_below(item, firstAboveItem); 67 | }); 68 | } else { 69 | // 45.rc: _setupIndicators is done 70 | // Insert after 71 | qs._indicators.insert_child_above(indicator, qs[after]); 72 | 73 | // Insert above 74 | const firstAboveItem = aboveIndicator.quickSettingsItems.at(-1); 75 | qs._addItemsBefore(indicatorItems, firstAboveItem, colSpan); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/modules/schedule-info.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Deminder 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | import GLib from 'gi://GLib'; 5 | import { gettext as _, ngettext as _n, pgettext as C_ } from './translation.js'; 6 | import { mapLegacyAction, untilText } from '../dbus-service/action.js'; 7 | 8 | export class ScheduleInfo { 9 | constructor({ mode = '?', deadline = -1, external = false }) { 10 | this._v = { mode: mapLegacyAction(mode), deadline, external }; 11 | } 12 | 13 | copy(vals) { 14 | return new ScheduleInfo({ ...this._v, ...vals }); 15 | } 16 | 17 | get deadline() { 18 | return this._v.deadline; 19 | } 20 | 21 | get external() { 22 | return this._v.external; 23 | } 24 | 25 | get mode() { 26 | return this._v.mode; 27 | } 28 | 29 | get scheduled() { 30 | return this.deadline > -1; 31 | } 32 | 33 | get secondsLeft() { 34 | return this.deadline - GLib.DateTime.new_now_utc().to_unix(); 35 | } 36 | 37 | get minutes() { 38 | return Math.floor(this.secondsLeft / 60); 39 | } 40 | 41 | get label() { 42 | let label = ''; 43 | if (this.scheduled) { 44 | label = _('{durationString} until {untiltext}') 45 | .replace('{durationString}', durationString(this.secondsLeft)) 46 | .replace('{untiltext}', untilText(this.mode)); 47 | if (this.external) { 48 | label = _('{label} (sys)').replace('{label}', label); 49 | } 50 | } 51 | return label; 52 | } 53 | 54 | get absoluteTimeString() { 55 | return GLib.DateTime.new_from_unix_utc(this.deadline) 56 | .to_local() 57 | .format(C_('absolute schedule notation', '%a, %T')); 58 | } 59 | 60 | isMoreUrgendThan(otherInfo) { 61 | return ( 62 | !otherInfo.scheduled || 63 | (this.scheduled && 64 | // external deadline is instant, internal deadline has 1 min slack time 65 | (this.external ? this.deadline : this.deadline + 58) < 66 | otherInfo.deadline) 67 | ); 68 | } 69 | } 70 | 71 | export function getShutdownScheduleFromSettings(settings) { 72 | return new ScheduleInfo({ 73 | mode: settings.get_string('shutdown-mode-value'), 74 | deadline: settings.get_int('shutdown-timestamp-value'), 75 | }); 76 | } 77 | 78 | export function getSliderMinutesFromSettings(settings, prefix) { 79 | const sliderValue = settings.get_double(`${prefix}-slider-value`) / 100.0; 80 | const rampUp = settings.get_double(`nonlinear-${prefix}-slider-value`); 81 | const ramp = x => Math.expm1(rampUp * x) / Math.expm1(rampUp); 82 | let minutes = Math.floor( 83 | (rampUp === 0 ? sliderValue : ramp(sliderValue)) * 84 | settings.get_int(`${prefix}-max-timer-value`) 85 | ); 86 | 87 | const refstr = settings.get_string(`${prefix}-ref-timer-value`); 88 | // default: 'now' 89 | const MS = 1000 * 60; 90 | if (refstr.includes(':')) { 91 | const mh = refstr 92 | .split(':') 93 | .map(s => Number.parseInt(s)) 94 | .filter(n => !Number.isNaN(n) && n >= 0); 95 | if (mh.length >= 2) { 96 | const d = new Date(); 97 | const nowTime = d.getTime(); 98 | d.setHours(mh[0]); 99 | d.setMinutes(mh[1]); 100 | 101 | if (d.getTime() + MS * minutes < nowTime) { 102 | d.setDate(d.getDate() + 1); 103 | } 104 | minutes += Math.floor(new Date(d.getTime() - nowTime).getTime() / MS); 105 | } 106 | } else if (prefix !== 'shutdown' && refstr === 'shutdown') { 107 | minutes += getSliderMinutesFromSettings(settings, 'shutdown'); 108 | } 109 | return minutes; 110 | } 111 | 112 | /** 113 | * A short duration string showing >=3 hours, >=1 mins, or secs. 114 | * 115 | * @param {number} seconds duration in seconds 116 | */ 117 | export function durationString(seconds) { 118 | const sign = Math.sign(seconds); 119 | const absSec = Math.floor(Math.abs(seconds)); 120 | const minutes = Math.floor(absSec / 60); 121 | const hours = Math.floor(minutes / 60); 122 | if (hours >= 3) { 123 | return _n('%s hour', '%s hours', hours).format(sign * hours); 124 | } else if (minutes === 0) { 125 | return _n('%s sec', '%s secs', absSec).format( 126 | sign * (absSec > 5 ? 10 * Math.ceil(absSec / 10) : absSec) 127 | ); 128 | } 129 | return _n('%s min', '%s mins', minutes).format(sign * minutes); 130 | } 131 | 132 | /** 133 | * 134 | * @param minutes 135 | * @param hrFmt 136 | * @param minFmt 137 | */ 138 | export function longDurationString(minutes, hrFmt, minFmt) { 139 | const hours = Math.floor(minutes / 60); 140 | const residualMinutes = minutes % 60; 141 | let parts = [minFmt(residualMinutes).format(residualMinutes)]; 142 | if (hours) { 143 | parts = [hrFmt(hours).format(hours)].concat(parts); 144 | } 145 | return parts.join(' '); 146 | } 147 | 148 | export function absoluteTimeString(minutes, timeFmt) { 149 | return GLib.DateTime.new_now_local().add_minutes(minutes).format(timeFmt); 150 | } 151 | -------------------------------------------------------------------------------- /src/modules/session-mode-aware.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Deminder 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 5 | 6 | export function currentSessionMode() { 7 | return Main.sessionMode.currentMode; 8 | } 9 | 10 | /** 11 | * Check if we want to show foreground activity 12 | */ 13 | export function foregroundActive() { 14 | // ubuntu22.04 uses 'ubuntu' as 'user' sessionMode 15 | return Main.sessionMode.currentMode !== 'unlock-dialog'; 16 | } 17 | 18 | /** 19 | * Observe foreground activity changes 20 | * 21 | * @param {object} obj bind obj as observer 22 | * @param {Function} callback called upon change 23 | */ 24 | export function observeForegroundActive(obj, callback) { 25 | if (!obj._sessionModeSignalId) { 26 | obj._sessionModeSignalId = Main.sessionMode.connect('updated', () => { 27 | callback(foregroundActive()); 28 | }); 29 | } 30 | callback(foregroundActive()); 31 | } 32 | 33 | /** 34 | * Unobserve session mode 35 | */ 36 | export function unobserveForegroundActive(obj) { 37 | if (obj._sessionModeSignalId) { 38 | Main.sessionMode.disconnect(obj._sessionModeSignalId); 39 | delete obj._sessionModeSignalId; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/modules/text-box.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Deminder 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 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 { throttleTimeout, logDebug } from './util.js'; 9 | import { 10 | foregroundActive, 11 | observeForegroundActive, 12 | unobserveForegroundActive, 13 | } from './session-mode-aware.js'; 14 | 15 | export class Textbox { 16 | textboxes = []; 17 | constructor({ settings }) { 18 | this.settings = settings; 19 | [this.syncThrottled, this.syncThrottledCancel] = throttleTimeout( 20 | this.sync.bind(this), 21 | 50 22 | ); 23 | this.showSettingsId = settings.connect( 24 | 'changed::show-textboxes-value', 25 | this.sync.bind(this) 26 | ); 27 | observeForegroundActive(this, fgActive => { 28 | if (!fgActive) { 29 | this.hideAll(); 30 | } 31 | }); 32 | } 33 | 34 | destroy() { 35 | if (this.showSettingsId) { 36 | unobserveForegroundActive(this); 37 | this.hideAll(); 38 | this.settings.disconnect(this.showSettingsId); 39 | this.showSettingsId = null; 40 | } 41 | } 42 | 43 | sync() { 44 | this.syncThrottledCancel(); 45 | // remove hidden textboxes 46 | this.textboxes = this.textboxes.filter(t => { 47 | if (t['_hidden']) { 48 | const sid = t['_sourceId']; 49 | if (sid) { 50 | clearTimeout(sid); 51 | } 52 | delete t['_sourceId']; 53 | t.destroy(); 54 | } 55 | return !t['_hidden']; 56 | }); 57 | const monitor = Main.layoutManager.primaryMonitor; 58 | let heightOffset = 0; 59 | this.textboxes.forEach((textbox, i) => { 60 | if (!('_sourceId' in textbox)) { 61 | // start fadeout of textbox after 3 seconds 62 | textbox['_sourceId'] = setTimeout(() => { 63 | textbox['_sourceId'] = 0; 64 | textbox.ease({ 65 | opacity: 0, 66 | duration: 1000, 67 | mode: Clutter.AnimationMode.EASE_OUT_QUAD, 68 | onComplete: () => { 69 | textbox['_hidden'] = 1; 70 | this.syncThrottled(); 71 | }, 72 | }); 73 | }, 3000); 74 | } 75 | textbox.visible = this.settings.get_boolean('show-textboxes-value'); 76 | if (!textbox.visible) return; 77 | 78 | if (i === 0) { 79 | heightOffset = -textbox.height / 2; 80 | } 81 | textbox.set_position( 82 | monitor.x + Math.floor(monitor.width / 2 - textbox.width / 2), 83 | monitor.y + Math.floor(monitor.height / 2 + heightOffset) 84 | ); 85 | heightOffset += textbox.height + 10; 86 | if (textbox['_sourceId']) { 87 | // set opacity before fadeout starts 88 | textbox.opacity = 89 | i === 0 90 | ? 255 91 | : Math.max( 92 | 25, 93 | 25 + 230 * (1 - heightOffset / (monitor.height / 2)) 94 | ); 95 | } 96 | }); 97 | } 98 | 99 | hideAll() { 100 | for (const t of this.textboxes) { 101 | t['_hidden'] = 1; 102 | } 103 | this.sync(); 104 | } 105 | 106 | /** 107 | * Show a textbox message on the primary monitor 108 | * 109 | * @param textmsg 110 | */ 111 | showTextbox(textmsg) { 112 | if (textmsg && foregroundActive()) { 113 | for (const t of this.textboxes) { 114 | // replace old textbox if it has the same text 115 | if (t.text === textmsg) { 116 | t['_hidden'] = 1; 117 | } 118 | } 119 | logDebug(`show textbox: ${textmsg}`); 120 | const textbox = new St.Label({ 121 | style_class: 'textbox-label', 122 | text: textmsg, 123 | opacity: 0, 124 | }); 125 | Main.uiGroup.add_child(textbox); 126 | this.textboxes.unshift(textbox); 127 | this.syncThrottled(); 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/modules/translation.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Deminder 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | import GLib from 'gi://GLib'; 5 | 6 | /** 7 | * The import paths for translations differ for `extension.js` and `prefs.js`. 8 | * https://gjs.guide/extensions/upgrading/gnome-shell-45.html#esm 9 | * 10 | * This module provides shared access to translations. 11 | * It is assumed that the gettext-domain is already bound. 12 | */ 13 | const domain = 'ShutdownTimer'; 14 | 15 | /** 16 | * Translate `str` using the extension's gettext domain 17 | * 18 | * @param {string} str - the string to translated 19 | * 20 | * @returns {string} the translated string 21 | */ 22 | export function gettext(str) { 23 | return GLib.dgettext(domain, str); 24 | } 25 | 26 | /** 27 | * Translate `str` and choose plural form using the extension's 28 | * gettext domain 29 | * 30 | * @param {string} str - the string to translate 31 | * @param {string} strPlural - the plural form of the string 32 | * @param {number} n - the quantity for which translation is needed 33 | * 34 | * @returns {string} the translated string 35 | */ 36 | export function ngettext(str, strPlural, n) { 37 | return GLib.dngettext(domain, str, strPlural, n); 38 | } 39 | 40 | /** 41 | * Translate `str` in the context of `context` using the extension's 42 | * gettext domain 43 | * 44 | * @param {string} context - context to disambiguate `str` 45 | * @param {string} str - the string to translate 46 | * 47 | * @returns {string} the translated string 48 | */ 49 | export function pgettext(context, str) { 50 | return GLib.dpgettext2(domain, context, str); 51 | } 52 | -------------------------------------------------------------------------------- /src/modules/util.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Deminder 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | import GLib from 'gi://GLib'; 5 | import Gio from 'gi://Gio'; 6 | 7 | export const debugMode = false; 8 | 9 | /** 10 | * Log debug message if debug is enabled . 11 | * 12 | * @param {...any} args log arguments 13 | */ 14 | export function logDebug(...args) { 15 | if ('logDebug' in globalThis) { 16 | globalThis.logDebug(...args); 17 | } else if (debugMode) { 18 | console.log('[SDT]', ...args); 19 | } 20 | } 21 | 22 | export async function proxyPromise( 23 | ProxyTypeOrName, 24 | session, 25 | dest, 26 | objectPath, 27 | cancellable = null 28 | ) { 29 | if (typeof ProxyTypeOrName === 'string') { 30 | try { 31 | ProxyTypeOrName = Gio.DBusProxy.makeProxyWrapper( 32 | await loadInterfaceXML(ProxyTypeOrName, cancellable) 33 | ); 34 | } catch (err) { 35 | throw new Error('Failed to load proxy interface!', { cause: err }); 36 | } 37 | } 38 | const p = await new Promise((resolve, reject) => { 39 | new ProxyTypeOrName( 40 | session, 41 | dest, 42 | objectPath, 43 | (proxy, error) => { 44 | if (error) { 45 | reject(error); 46 | } else { 47 | resolve(proxy); 48 | } 49 | }, 50 | cancellable 51 | ); 52 | }); 53 | return p; 54 | } 55 | 56 | export class Idle { 57 | #idleSourceId = null; 58 | #idleResolves = []; 59 | 60 | destroy() { 61 | // Ensure that promises are resolved 62 | for (const resolve of this.#idleResolves) { 63 | resolve(); 64 | } 65 | this.#idleResolves = []; 66 | if (this.#idleSourceId) { 67 | GLib.Source.remove(this.#idleSourceId); 68 | } 69 | this.#idleSourceId = null; 70 | } 71 | 72 | /** 73 | * Resolves when event loop is idle 74 | */ 75 | guiIdle() { 76 | return new Promise(resolve => { 77 | this.#idleResolves.push(resolve); 78 | if (!this.#idleSourceId) { 79 | this.#idleSourceId = GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { 80 | for (const res of this.#idleResolves) { 81 | res(); 82 | } 83 | this.#idleResolves = []; 84 | this.#idleSourceId = null; 85 | return GLib.SOURCE_REMOVE; 86 | }); 87 | } 88 | }); 89 | } 90 | } 91 | 92 | /** 93 | * Calls to `event` are delayed (throttled). 94 | * Call `cancel` drop last event. 95 | * 96 | * @param {Function} timeoutFunc called function after delay 97 | * @param {number} delayMillis delay in milliseconds 98 | * @returns [event, cancel] 99 | */ 100 | export function throttleTimeout(timeoutFunc, delayMillis) { 101 | let current = null; 102 | return [ 103 | () => { 104 | if (current === null) { 105 | current = setTimeout(() => { 106 | current = null; 107 | timeoutFunc(); 108 | }, delayMillis); 109 | } 110 | }, 111 | () => { 112 | if (current) { 113 | clearTimeout(current); 114 | current = null; 115 | } 116 | }, 117 | ]; 118 | } 119 | 120 | export function extensionDirectory() { 121 | const utilModulePath = /(.*)@file:\/\/(.*):\d+:\d+$/.exec( 122 | new Error().stack.split('\n')[1] 123 | )[2]; 124 | const extOrModuleDir = GLib.path_get_dirname( 125 | GLib.path_get_dirname(utilModulePath) 126 | ); 127 | // This file is either at /modules/util.js or /modules/sdt/util.js 128 | return GLib.path_get_basename(extOrModuleDir) === 'modules' 129 | ? GLib.path_get_dirname(extOrModuleDir) 130 | : extOrModuleDir; 131 | } 132 | 133 | export function readFileAsync(pathOrFile, cancellable = null) { 134 | return new Promise((resolve, reject) => { 135 | try { 136 | const file = 137 | typeof pathOrFile === 'string' 138 | ? Gio.File.new_for_path(pathOrFile) 139 | : pathOrFile; 140 | file.load_contents_async(cancellable, (f, res) => { 141 | try { 142 | const [, contents] = f.load_contents_finish(res); 143 | const decoder = new TextDecoder('utf-8'); 144 | resolve(decoder.decode(contents)); 145 | } catch (err) { 146 | reject(err); 147 | } 148 | }); 149 | } catch (err) { 150 | reject(err); 151 | } 152 | }); 153 | } 154 | 155 | export async function loadInterfaceXML(iface, cancellable = null) { 156 | const readPromises = [ 157 | Gio.File.new_for_path( 158 | `${extensionDirectory()}/dbus-interfaces/${iface}.xml` 159 | ), 160 | Gio.File.new_for_uri( 161 | `resource:///org/gnome/shell/dbus-interfaces/${iface}.xml` 162 | ), 163 | ].map(async file => { 164 | try { 165 | return await readFileAsync(file, cancellable); 166 | } catch (err) { 167 | return ''; 168 | } 169 | }); 170 | for await (const xml of readPromises) { 171 | if (xml) return xml; 172 | } 173 | throw new Error( 174 | `Failed to load D-Bus interface '${iface}'${ 175 | cancellable && cancellable.is_cancelled() ? ' (canceled)' : '' 176 | }` 177 | ); 178 | } 179 | -------------------------------------------------------------------------------- /src/polkit/10-dem.shutdowntimer.settimers.rules: -------------------------------------------------------------------------------- 1 | polkit.addRule(function(action, subject) { 2 | if (action.id == "org.freedesktop.policykit.exec" && 3 | action.lookup("program") == "{{TOOL_OUT}}" && 4 | subject.isInGroup("{{TOOL_USER}}")) 5 | { 6 | return polkit.Result.YES; 7 | } 8 | }); 9 | -------------------------------------------------------------------------------- /src/polkit/10-dem.shutdowntimer.settimers.rules.legacy: -------------------------------------------------------------------------------- 1 | polkit.addRule(function (action, subject) { 2 | var idx = action.id.lastIndexOf("."); 3 | var username_stripped = action.id.substring(0, idx); 4 | var username = action.id.substring(idx + 1); 5 | if (username_stripped === "{{RULE_BASE}}") { 6 | if (subject.user === username) { 7 | return polkit.Result.YES; 8 | } else { 9 | return polkit.Result.NO; 10 | } 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /src/polkit/dem.shutdowntimer.policy.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Shutdown Timer 5 | https://github.com/Deminder/ShutdownTimer 6 | 7 | 8 | Control shutdown and rtc wake alarm schedule 9 | Steuerung des Ausschalt-Planers und des RTC-Weck-Alarms 10 | No Authorization required to control shutdown or rtc wake alarm. 11 | Keine Autorisierung zur Steuerung des Ausschalt-Planers oder RTC-Weck-Alarms notwendig. 12 | 13 | yes 14 | yes 15 | yes 16 | 17 | {{PATH}} 18 | 3.0.0 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/prefs.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Deminder 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | import Gtk from 'gi://Gtk'; 5 | import Gio from 'gi://Gio'; 6 | 7 | import { ExtensionPreferences } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; 8 | 9 | import { Install } from './modules/install.js'; 10 | import { 11 | actionLabel, 12 | ACTIONS, 13 | mapLegacyAction, 14 | supportedActions, 15 | } from './dbus-service/action.js'; 16 | import { logDebug, Idle } from './modules/util.js'; 17 | 18 | const templateComponents = { 19 | shutdown: { 20 | 'shutdown-mode': 'combo', 21 | 'root-mode': 'switch', 22 | 'show-end-session-dialog': 'switch', 23 | 'shutdown-max-timer': 'adjustment', 24 | 'shutdown-ref-timer': 'buffer', 25 | 'shutdown-slider': 'adjustment', 26 | 'nonlinear-shutdown-slider': 'adjustment', 27 | }, 28 | wake: { 29 | 'auto-wake': 'switch', 30 | 'wake-max-timer': 'adjustment', 31 | 'wake-ref-timer': 'buffer', 32 | 'wake-slider': 'adjustment', 33 | 'nonlinear-wake-slider': 'adjustment', 34 | }, 35 | display: { 36 | 'show-settings': 'switch', 37 | 'show-shutdown-mode': 'buffer', 38 | 'show-shutdown-slider': 'switch', 39 | 'show-shutdown-indicator': 'switch', 40 | 'show-shutdown-absolute-timer': 'switch', 41 | 'show-textboxes': 'switch', 42 | 'show-wake-slider': 'switch', 43 | 'show-wake-items': 'switch', 44 | 'show-wake-absolute-timer': 'switch', 45 | }, 46 | }; 47 | 48 | export default class ShutdownTimerPreferences extends ExtensionPreferences { 49 | /** 50 | * Fill the preferences window with preferences. 51 | * 52 | * The default implementation adds the widget 53 | * returned by getPreferencesWidget(). 54 | * 55 | * @param {Adw.PreferencesWindow} window - the preferences window 56 | */ 57 | fillPreferencesWindow(window) { 58 | const builder = Gtk.Builder.new(); 59 | builder.add_from_file( 60 | this.dir.get_child('ui').get_child('prefs.ui').get_path() 61 | ); 62 | 63 | const settings = this.getSettings(); 64 | const handlers = []; 65 | const pageNames = ['install', 'shutdown', 'wake', 'display'].map( 66 | n => `shutdowntimer-prefs-${n}` 67 | ); 68 | for (const name of pageNames) { 69 | const pageId = name.replaceAll('-', '_'); 70 | const page = builder.get_object(pageId); 71 | const pageName = pageId.split('_').at(-1); 72 | if (!page) { 73 | throw new Error(`${pageId} not found!`); 74 | } 75 | if (pageName === 'install') { 76 | const idle = new Idle(); 77 | const install = new Install(); 78 | this.initInstallPage( 79 | builder, 80 | this.dir.get_child('tool').get_child('installer.sh').get_path(), 81 | install, 82 | idle 83 | ); 84 | window.connect('destroy', () => { 85 | idle.destroy(); 86 | install.destroy(); 87 | }); 88 | } 89 | this.initPage(pageName, builder, settings); 90 | window.add(page); 91 | } 92 | const selPageName = 93 | pageNames[settings.get_int('preferences-selected-page-value')]; 94 | if (selPageName) { 95 | window.set_visible_page_name(selPageName); 96 | } 97 | const pageVisHandlerId = window.connect('notify::visible-page-name', () => { 98 | logDebug(window.get_visible_page_name()); 99 | settings.set_int( 100 | 'preferences-selected-page-value', 101 | pageNames.indexOf(window.get_visible_page_name()) 102 | ); 103 | }); 104 | handlers.push([window, pageVisHandlerId]); 105 | 106 | window.connect('destroy', () => { 107 | handlers.forEach(([comp, handlerId]) => { 108 | comp.disconnect(handlerId); 109 | }); 110 | }); 111 | } 112 | 113 | async initShutdownModeCombo(settings, comp) { 114 | const model = new Gtk.StringList(); 115 | const actionIds = []; 116 | try { 117 | for await (const action of supportedActions()) { 118 | model.append(actionLabel(action)); 119 | actionIds.push(ACTIONS[action]); 120 | } 121 | } catch (err) { 122 | console.error(err); 123 | } 124 | comp.model = model; 125 | const updateComboRow = () => { 126 | const actionId = 127 | ACTIONS[mapLegacyAction(settings.get_string('shutdown-mode-value'))]; 128 | const index = actionIds.indexOf(actionId); 129 | if (index >= 0) comp.selected = index; 130 | }; 131 | comp.connect('notify::selected', () => { 132 | const actionId = actionIds[comp.selected]; 133 | const action = Object.entries(ACTIONS).find( 134 | ([_, id]) => id === actionId 135 | )[0]; 136 | if (action) settings.set_string('shutdown-mode-value', action); 137 | }); 138 | const comboHandlerId = settings.connect( 139 | 'changed::shutdown-mode-value', 140 | () => updateComboRow() 141 | ); 142 | comp.connect('destroy', () => settings.disconnect(comboHandlerId)); 143 | updateComboRow(); 144 | } 145 | 146 | initPage(pageName, builder, settings) { 147 | if (pageName in templateComponents) { 148 | for (const [baseName, component] of Object.entries( 149 | templateComponents[pageName] 150 | )) { 151 | const baseId = baseName.replaceAll('-', '_'); 152 | const settingsName = `${baseName}-value`; 153 | const compId = `${baseId}_${component}`; 154 | const comp = builder.get_object(compId); 155 | if (!comp) { 156 | throw new Error(`Component not found in template: ${compId}`); 157 | } 158 | if (compId === 'shutdown_mode_combo') { 159 | this.initShutdownModeCombo(settings, comp); 160 | } else { 161 | settings.bind( 162 | settingsName, 163 | comp, 164 | { 165 | adjustment: 'value', 166 | switch: 'active', 167 | textbuffer: 'text', 168 | buffer: 'text', 169 | }[component], 170 | Gio.SettingsBindFlags.DEFAULT 171 | ); 172 | } 173 | 174 | if ( 175 | [ 176 | 'show_wake_slider_switch', 177 | 'show_wake_absolute_timer_switch', 178 | ].includes(compId) 179 | ) 180 | this.switchDependsOnSetting(comp, settings, 'show-wake-items-value'); 181 | } 182 | } 183 | } 184 | 185 | switchDependsOnSetting(comp, settings, settingsName) { 186 | const update = () => { 187 | const active = settings.get_boolean(settingsName); 188 | comp.sensitive = active; 189 | const row = comp.get_parent().get_parent(); 190 | row.sensitive = active; 191 | }; 192 | const handlerId = settings.connect(`changed::${settingsName}`, () => 193 | update() 194 | ); 195 | comp.connect('destroy', () => settings.disconnect(handlerId)); 196 | update(); 197 | } 198 | 199 | initInstallPage(builder, installerScriptPath, install, idle) { 200 | // install log textbuffer updates 201 | const logTextBuffer = builder.get_object('install_log_textbuffer'); 202 | const scrollAdj = builder.get_object('installer_scrollbar_adjustment'); 203 | const errorTag = new Gtk.TextTag({ foreground: 'red' }); 204 | const successTag = new Gtk.TextTag({ foreground: 'green' }); 205 | const table = logTextBuffer.get_tag_table(); 206 | table.add(errorTag); 207 | table.add(successTag); 208 | 209 | const installSwitch = builder.get_object('install_policy_switch'); 210 | installSwitch.set_active(install.checkInstalled()); 211 | installSwitch.connect('notify::active', () => 212 | install.installAction( 213 | installerScriptPath, 214 | installSwitch.get_active() ? 'install' : 'uninstall', 215 | async message => { 216 | await idle.guiIdle(); 217 | // Format log lines 218 | message.split('\n').forEach(line => { 219 | line = ['[', '#'].includes(line[0]) ? line : ` ${line}`; 220 | const b = logTextBuffer; 221 | b.insert(b.get_end_iter(), `${line}\n`, -1); 222 | const end = b.get_end_iter(); 223 | const start = end.copy(); 224 | if (start.backward_line()) { 225 | if (line.startsWith('# ')) { 226 | b.apply_tag(errorTag, start, end); 227 | } else if (line.endsWith('🟢')) { 228 | b.apply_tag(successTag, start, end); 229 | } 230 | } 231 | }); 232 | await idle.guiIdle(); 233 | scrollAdj.set_value(1000000); 234 | } 235 | ) 236 | ); 237 | // Clear install log 238 | logTextBuffer.set_text('', -1); 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /src/schemas/org.gnome.shell.extensions.shutdowntimer-deminder.gschema.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | 10 | 11 | 12 | 180 13 | Maximum shutdown time (in minutes) 14 | Set maximum selectable shutdown time of the slider (in minutes). Use only values greater zero. 15 | 16 | 17 | 18 | 1440 19 | Maximum wake time (in minutes) 20 | Set maximum selectable wake time of the slider (in minutes). Use only values greater zero. 21 | 22 | 23 | 24 | "now" 25 | Reference start time for shutdown 26 | Reference start time for shutdown either now or HH:MM 27 | 28 | 29 | 30 | true 31 | Display shutdown time as absolute timestamp 32 | Display shutdown time as absolute timestamp "HH:MM" 33 | 34 | 35 | 36 | "now" 37 | Reference start time for wake 38 | Reference start time for wake either now, shutdown, or HH:MM 39 | 40 | 41 | 42 | true 43 | Display wake time as absolute timestamp 44 | Display wake time as absolute timestamp "HH:MM" 45 | 46 | 47 | 48 | false 49 | Automatically start and stop wake on shutdown timer toggle 50 | Enable/Disable the wake alarm when the shutdown timer is started/stopped. 51 | 52 | 53 | 54 | -1 55 | Scheduled shutdown timestamp. 56 | Unix time in seconds of scheduled shutdown or -1 if disabled. 57 | 58 | 59 | 60 | 70 61 | Wake slider position (in percent) 62 | Set wake slider position as percent of the maximum time. Must be in range 0 and 100. 63 | 64 | 65 | 66 | 1.5 67 | Ramp-up of non-linear wake slider value 68 | Exponential ramp-up for wake time slider 69 | 70 | 71 | 72 | 70 73 | Shutdown slider position (in percent) 74 | Set shutdown slider position as percent of the maximum time. Must be in range 0 and 100. 75 | 76 | 77 | 78 | 0 79 | Ramp-up of non-linear shutdown slider value 80 | Exponential ramp-up for shutdown time slider 81 | 82 | 83 | 84 | true 85 | Show settings button 86 | Show/hide settings button in widget. 87 | 88 | 89 | 90 | true 91 | Show shutdown slider 92 | Show/hide shutdown slider in widget. 93 | 94 | 95 | 96 | true 97 | Show wake slider 98 | Show/hide wake slider in widget. 99 | 100 | 101 | 102 | false 103 | Show all wake items 104 | Show/hide all wake items in widget. 105 | 106 | 107 | 108 | true 109 | Show notification text boxes 110 | Show/hide notification text boxes on screen. 111 | 112 | 113 | 114 | false 115 | Root mode 116 | Set root mode on/off. In root mode powering off is done via 'pkexec' and 'shutdown' terminal command. 117 | 118 | 119 | 120 | true 121 | Show end-session dialog 122 | Show the end-session dialog for reboot and shutdown if screensaver is inactive. 123 | 124 | 125 | 126 | "p,s" 127 | Shown shutdown modes 128 | Comma-separated shutdown modes which are shown in the popup menu (p: poweroff, s: suspend, r: reboot) 129 | 130 | 131 | 132 | "poweroff" 133 | Use mode 134 | Mode to use for timer action 135 | 136 | 137 | 138 | true 139 | Show shutdown indicator 140 | Shows the remaining time until shutdown action 141 | 142 | 143 | 144 | 0 145 | Last selected page in the preferences. 146 | Last selected page in the preferences. 147 | 148 | 149 | 150 | 151 | -------------------------------------------------------------------------------- /src/stylesheet.css: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: Daniel Neumann 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | .textbox-label { 7 | font-size: 2em; 8 | font-weight: bold; 9 | color: #ffffff; 10 | background-color: rgba(10, 10, 10, 0.7); 11 | border-radius: 0.5em; 12 | padding: 0.5em; 13 | } 14 | 15 | .settings-button { 16 | padding: 4px; 17 | border-radius: 32px; 18 | margin: 1px; 19 | } 20 | 21 | .settings-button:hover, 22 | .settings-button:focus { 23 | padding: 5px; 24 | margin: 0px; 25 | } 26 | 27 | .settings-button > StIcon { 28 | icon-size: 15px; 29 | } 30 | -------------------------------------------------------------------------------- /src/tool/installer.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # SPDX-FileCopyrightText: 2023 Deminder 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | # 6 | # Adapted from cpupower extension by: 7 | # Martin Koppehel , Fin Christensen 8 | 9 | # installer.sh - This script installs a policykit rule for the Shutdown Timer gnome-shell extension. 10 | # 11 | # This file is part of the gnome-shell extension ShutdownTimer@Deminder. 12 | 13 | 14 | set -e 15 | 16 | ################################ 17 | # EXTENSION SPECIFIC OPTIONS: # 18 | ################################ 19 | 20 | EXTENSION_NAME="Shutdown Timer" 21 | ACTION_BASE="dem.shutdowntimer" 22 | RULE_BASE="$ACTION_BASE.settimers" 23 | CFC_BASE="shutdowntimerctl" 24 | POLKIT_DIR="polkit" 25 | VERSION=1 26 | 27 | 28 | EXIT_SUCCESS=0 29 | EXIT_INVALID_ARG=1 30 | EXIT_FAILED=2 31 | EXIT_NEEDS_UPDATE=3 32 | EXIT_NEEDS_SECURITY_UPDATE=4 33 | EXIT_NOT_INSTALLED=5 34 | EXIT_MUST_BE_ROOT=6 35 | 36 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" #stackoverflow 59895 37 | 38 | export TEXTDOMAINDIR="$DIR/../locale" 39 | export TEXTDOMAIN="ShutdownTimer" 40 | function gtxt() { 41 | gettext "$1" 42 | } 43 | 44 | function recent_polkit() { 45 | printf -v versions '%s\n%s' "$(pkaction --version | cut -d' ' -f3)" "0.106" 46 | if [[ $versions != "$(sort -V <<< "$versions")" ]];then 47 | echo "available" 48 | else 49 | echo "unavailable" 50 | fi 51 | } 52 | 53 | function check_support() { 54 | RECENT_STR=", stand-alone polkit rules $(recent_polkit)" 55 | if which rtcwake >/dev/null 2>&1 56 | then 57 | echo "rtcwake supported${RECENT_STR}" 58 | exit ${EXIT_SUCCESS} 59 | else 60 | echo "rtcwake unsupported${RECENT_STR}" 61 | exit ${EXIT_FAILED} 62 | fi 63 | } 64 | 65 | function fail() { 66 | echo "$(gtxt "Failed")${1}" >&2 && exit ${EXIT_FAILED} 67 | } 68 | DEFAULT_SUCCESS_MSG=$(gtxt 'Success') 69 | 70 | function success() { 71 | echo -n "${1:-$DEFAULT_SUCCESS_MSG}" 72 | echo -e "\U1F7E2" 73 | } 74 | 75 | 76 | 77 | ######################## 78 | # GENERALIZED SCRIPT: # 79 | ######################## 80 | 81 | function usage() { 82 | echo "Usage: installer.sh [options] {supported,install,check,update,uninstall}" 83 | echo 84 | echo "Available options:" 85 | echo " --tool-user USER Set the user of the tool (default: \$USER)" 86 | echo 87 | exit ${EXIT_INVALID_ARG} 88 | } 89 | 90 | if [ $# -lt 1 ] 91 | then 92 | usage 93 | fi 94 | 95 | ACTION="" 96 | TOOL_USER="$USER" 97 | while [[ $# -gt 0 ]] 98 | do 99 | key="$1" 100 | 101 | # we have to use command line arguments here as pkexec does not support 102 | # setting environment variables 103 | case $key in 104 | --tool-user) 105 | TOOL_USER="$2" 106 | shift 107 | shift 108 | ;; 109 | supported|install|check|update|uninstall) 110 | if [ -z "$ACTION" ] 111 | then 112 | ACTION="$1" 113 | else 114 | echo "Too many actions specified. Please give at most 1." 115 | usage 116 | fi 117 | shift 118 | ;; 119 | *) 120 | echo "Unknown argument $key" 121 | usage 122 | ;; 123 | esac 124 | done 125 | 126 | 127 | CFC_DIR="/usr/local/bin" 128 | RULE_DIR="/etc/polkit-1/rules.d" 129 | 130 | RULE_IN="${DIR}/../${POLKIT_DIR}/10-$RULE_BASE.rules" 131 | if [[ "$(recent_polkit)" != "available" ]];then 132 | RULE_IN="${RULE_IN}.legacy" 133 | ACTION_IN="${DIR}/../${POLKIT_DIR}/${ACTION_BASE}.policy.in" 134 | fi 135 | TOOL_IN="${DIR}/$CFC_BASE" 136 | 137 | TOOL_OUT="${CFC_DIR}/${CFC_BASE}-${TOOL_USER}" 138 | RULE_OUT="${RULE_DIR}/10-${RULE_BASE}-${TOOL_USER}.rules" 139 | ACTION_ID="${RULE_BASE}.${TOOL_USER}" 140 | ACTION_OUT="/usr/share/polkit-1/actions/${ACTION_ID}.policy" 141 | 142 | function print_policy_xml() { 143 | sed -e "s:{{PATH}}:${TOOL_OUT}:g" \ 144 | -e "s:{{ACTION_BASE}}:${ACTION_BASE}:g" \ 145 | -e "s:{{ACTION_ID}}:${ACTION_ID}:g" "${ACTION_IN}" 146 | } 147 | 148 | function print_rules_javascript() { 149 | if [[ "$RULE_IN" == *.legacy ]]; then 150 | sed -e "s:{{RULE_BASE}}:${RULE_BASE}:g" "${RULE_IN}" 151 | else 152 | sed -e "s:{{TOOL_OUT}}:${TOOL_OUT}:g" \ 153 | -e "s:{{TOOL_USER}}:${TOOL_USER}:g" "${RULE_IN}" 154 | fi 155 | 156 | } 157 | 158 | if [ "$ACTION" = "supported" ] 159 | then 160 | check_support 161 | fi 162 | 163 | if [ "$ACTION" = "check" ] 164 | then 165 | if ! print_rules_javascript | cmp --silent "${RULE_OUT}" 166 | then 167 | if [ -f "${ACTION_OUT}" ] 168 | then 169 | echo "Your $EXTENSION_NAME installation needs updating!" 170 | exit ${EXIT_NEEDS_UPDATE} 171 | else 172 | echo "Not installed" 173 | exit ${EXIT_NOT_INSTALLED} 174 | fi 175 | fi 176 | echo "Installed" 177 | 178 | exit ${EXIT_SUCCESS} 179 | fi 180 | 181 | TOOL_NAME=$(basename ${TOOL_OUT}) 182 | 183 | if [ "$ACTION" = "install" ] 184 | then 185 | if [ "${EUID}" -ne 0 ]; then 186 | echo "The install action must be run as root for security reasons!" 187 | echo "Please have a look at https://github.com/martin31821/cpupower/issues/102" 188 | echo "for further details." 189 | exit ${EXIT_MUST_BE_ROOT} 190 | fi 191 | 192 | echo -n "$(gtxt 'Installing') ${TOOL_NAME} $(gtxt 'tool')... " 193 | mkdir -p "${CFC_DIR}" 194 | install "${TOOL_IN}" "${TOOL_OUT}" || fail 195 | success 196 | 197 | if [ ! -z "$ACTION_IN" ];then 198 | echo "$(gtxt 'Using legacy policykit install')..." 199 | echo -n "$(gtxt 'Installing') $(gtxt 'policykit action')..." 200 | (print_policy_xml > "${ACTION_OUT}" 2>/dev/null && chmod 0644 "${ACTION_OUT}") || fail 201 | success 202 | fi 203 | 204 | echo -n "$(gtxt 'Installing') $(gtxt 'policykit rule')..." 205 | mkdir -p "${RULE_DIR}" 206 | (print_rules_javascript > "${RULE_OUT}" 2>/dev/null && chmod 0644 "${RULE_OUT}") || fail 207 | success 208 | 209 | exit ${EXIT_SUCCESS} 210 | fi 211 | 212 | if [ "$ACTION" = "update" ] 213 | then 214 | "${BASH_SOURCE[0]}" --tool-user "${TOOL_USER}" uninstall || exit $? 215 | "${BASH_SOURCE[0]}" --tool-user "${TOOL_USER}" install || exit $? 216 | 217 | exit ${EXIT_SUCCESS} 218 | fi 219 | 220 | if [ "$ACTION" = "uninstall" ] 221 | then 222 | LEG_CFG_OUT="/usr/bin/shutdowntimerctl-$TOOL_USER" 223 | if [ -f "$LEG_CFG_OUT" ] 224 | then 225 | # remove legacy "tool" install 226 | echo -n "$(gtxt 'Uninstalling') $(gtxt 'tool')..." 227 | rm "${LEG_CFG_OUT}" || fail " - $(gtxt 'cannot remove') ${LEG_CFG_OUT}" && success 228 | fi 229 | 230 | if [ -f "$ACTION_OUT" ] 231 | then 232 | # remove legacy "policykit action" install 233 | echo -n "$(gtxt 'Uninstalling') $(gtxt 'policykit action')..." 234 | rm "${ACTION_OUT}" || fail " - $(gtxt 'cannot remove') ${ACTION_OUT}" && success 235 | fi 236 | LEG_RULE_OUT="/usr/share/polkit-1/rules.d/10-dem.shutdowntimer.settimers.rules" 237 | if [ -f "$LEG_RULE_OUT" ] 238 | then 239 | # remove legacy "policykit action" install 240 | echo -n "$(gtxt 'Uninstalling') $(gtxt 'policykit rule')..." 241 | rm "${LEG_RULE_OUT}" || fail " - $(gtxt 'cannot remove') ${LEG_RULE_OUT}" && success 242 | fi 243 | 244 | function uninstallFile() { 245 | echo -n "$(gtxt 'Uninstalling') $2... " 246 | if [ -f "$1" ] 247 | then 248 | rm "$1" || fail " - $(gtxt 'cannot remove') $1" && success 249 | else 250 | echo "$2 $(gtxt 'not installed at') $1" 251 | fi 252 | } 253 | 254 | uninstallFile "${TOOL_OUT}" "${TOOL_NAME} $(gtxt 'tool')" 255 | uninstallFile "${RULE_OUT}" "$(gtxt 'policykit rule')" 256 | 257 | exit ${EXIT_SUCCESS} 258 | fi 259 | 260 | echo "Unknown parameter." 261 | usage 262 | 263 | 264 | -------------------------------------------------------------------------------- /src/tool/shutdowntimerctl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # SPDX-FileCopyrightText: 2023 Deminder 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | # 6 | # shutdowntimerctl - This script can configure the shutdown and rtc wake alarm schedule. 7 | # 8 | # This file is part of the gnome-shell extension ShutdownTimer@Deminder. 9 | 10 | SHUTDOWN_BIN=/usr/sbin/shutdown 11 | RTCWAKE_BIN=/usr/sbin/rtcwake 12 | 13 | SHUTDOWN_MODE="-P" 14 | if [ ! -z "$2" ] && [ "$2" -gt 0 ];then 15 | POSITIVE_VALUE="$2" 16 | fi 17 | 18 | function print_help() { 19 | echo "[help] (show this help)" >&2 20 | echo "[shutdown|reboot|shutdown-cancel] {MINUTES}" >&2 21 | echo "[wake|wake-cancel] {MINUTES} (default: 0)" >&2 22 | } 23 | 24 | if [ "$#" -lt 1 ]; then 25 | print_help 26 | exit 27 | fi 28 | 29 | case "$1" in 30 | shutdown|reboot) 31 | if [[ "$1" = "reboot" ]]; then 32 | SHUTDOWN_MODE="-r" 33 | fi 34 | $SHUTDOWN_BIN "$SHUTDOWN_MODE" "$POSITIVE_VALUE" 35 | ;; 36 | shutdown-cancel) 37 | $SHUTDOWN_BIN -c 38 | ;; 39 | wake) 40 | $RTCWAKE_BIN --date +${POSITIVE_VALUE:-0}min --mode no 41 | ;; 42 | wake-cancel) 43 | $RTCWAKE_BIN --mode disable 44 | ;; 45 | --version) 46 | echo 1 47 | ;; 48 | -h|help) 49 | print_help 50 | ;; 51 | *) 52 | echo "Invalid argument: $1" >&2 53 | print_help 54 | esac 55 | 56 | -------------------------------------------------------------------------------- /tests/injection.test.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Deminder 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | import { 5 | assert, 6 | assertEquals, 7 | permutations, 8 | product, 9 | describe, 10 | it, 11 | range, 12 | logOriginExcludes, 13 | } from './test-base.js'; 14 | import { InjectionTracker } from '../src/modules/injection.js'; 15 | 16 | await describe( 17 | 'injection tracker', 18 | it('should clear all', () => { 19 | const tracker1 = new InjectionTracker(); 20 | const tracker2 = new InjectionTracker(); 21 | const myobj = { 22 | val: 'orig', 23 | get prop() { 24 | return `${this.val}prop`; 25 | }, 26 | }; 27 | tracker1.clearAll(); 28 | assertEquals(myobj.prop, 'origprop', 'should be orig value'); 29 | tracker1.injectProperty(myobj, 'prop', 't1prop'); 30 | assertEquals(myobj.prop, 't1prop'); 31 | const inj = tracker2.injectProperty(myobj, 'prop', 't2prop'); 32 | assertEquals(myobj.prop, 't2prop'); 33 | assertEquals(inj.previous, 't1prop'); 34 | assertEquals(inj.original, 'origprop'); 35 | assertEquals(tracker1.localInjections.length, 1); 36 | assertEquals(tracker2.localInjections.length, 1); 37 | tracker2.clearAll(); 38 | assertEquals(tracker2.localInjections.length, 0); 39 | assertEquals(myobj.prop, 't1prop'); 40 | tracker2.injectProperty(myobj, 'prop', 't2prop2'); 41 | assertEquals(myobj.prop, 't2prop2'); 42 | tracker1.clearAll(); 43 | assertEquals(myobj.prop, 't2prop2', 'should only clear local injections'); 44 | tracker2.clearAll(); 45 | assertEquals(myobj.prop, 'origprop', 'should restore original'); 46 | assertEquals(tracker1.localInjections.length, 0); 47 | assertEquals(tracker2.localInjections.length, 0); 48 | }), 49 | 50 | it('should inject properties', () => { 51 | const tracker1 = new InjectionTracker(); 52 | const tracker2 = new InjectionTracker(); 53 | const tracker3 = new InjectionTracker(); 54 | const myobj = { 55 | _addItems(val) { 56 | log(`[orig] ${val}\n`); 57 | }, 58 | }; 59 | 60 | const injection = tracker1.injectProperty(myobj, '_addItems', val => { 61 | log(`[i1] ${val}`); 62 | if (val === 'test4') { 63 | injection.previous.call(myobj, ' -- from [i1]'); 64 | log('[i1] clear()'); 65 | injection.clear(); 66 | } 67 | injection.previous.call(myobj, val); 68 | }); 69 | 70 | const injection2 = tracker2.injectProperty(myobj, '_addItems', val => { 71 | log(`[i2] ${val}`); 72 | if (val === 'test2') { 73 | log('[i2] clear()'); 74 | injection2.clear(); 75 | injection2.previous.call(myobj, ' -- from [i2]'); 76 | } 77 | injection2.previous.call(myobj, val); 78 | }); 79 | 80 | const injection3 = tracker3.injectProperty(myobj, '_addItems', val => { 81 | if (val === 'test3') { 82 | log(`[i3] ${val}`); 83 | } 84 | injection3.previous.call(myobj, val); 85 | }); 86 | assertEquals( 87 | injection3.count, 88 | 3, 89 | 'injection3 should have 3 previous functions' 90 | ); 91 | injection3.original.call(myobj, 'origorig'); 92 | 93 | myobj._addItems('test'); 94 | myobj._addItems('test2'); 95 | assertEquals( 96 | injection3.count, 97 | 2, 98 | 'injection3 should have 2 previous functions' 99 | ); 100 | const injection4 = tracker1.injectProperty(myobj, '_addItems', val => { 101 | if (val === 'test') { 102 | log(`[i3] ${val}`); 103 | } 104 | injection4.previous.call(myobj, val); 105 | }); 106 | myobj._addItems('test2'); 107 | myobj._addItems('test'); 108 | myobj._addItems('test3'); 109 | myobj._addItems('test'); 110 | myobj._addItems('test4'); 111 | assertEquals( 112 | injection3.count, 113 | 1, 114 | 'injection3 should have 1 previous function' 115 | ); 116 | myobj._addItems('test4'); 117 | myobj._addItems('test'); 118 | assertEquals( 119 | injection3.count, 120 | 1, 121 | 'injection3 should have 1 previous function' 122 | ); 123 | injection3.clear(); 124 | assertEquals( 125 | injection3.count, 126 | 1, 127 | 'injection3 should have 1 previous function due to injection4' 128 | ); 129 | injection4.clear(); 130 | assertEquals( 131 | injection3.count, 132 | 0, 133 | 'injection3 should have no previous function' 134 | ); 135 | assert( 136 | myobj.__injectionHistories === undefined, 137 | 'should have cleared injection histories' 138 | ); 139 | log(''); 140 | myobj._addItems('test5'); 141 | injection3.previous.call(myobj, 'only orig'); 142 | injection3.original.call(myobj, 'only orig'); 143 | }), 144 | 145 | it('should not fail for arbitrary injection combinations', () => { 146 | logOriginExcludes.set('1', /\/modules\/injection\.js:\d+:\d+$/); 147 | const myobj = { 148 | _addItems: i => `orig${i}`, 149 | }; 150 | const injectionStateStr = (inj, message = '') => { 151 | const h = myobj.__injectionHistories._addItems; 152 | const c = bb => bb.map(b => (b ? 'x' : '.')).join(' '); 153 | return [ 154 | message, 155 | `hist ${c(h.map(v => v === undefined))}`, 156 | `prev ${c(h.map(d => Object.is(inj.previous, d?.value)))}`, 157 | `orig ${c(h.map(d => Object.is(inj.original, d?.value)))}`, 158 | ].join('\n'); 159 | }; 160 | const injectionHandlers = { 161 | t1: (inj, i) => { 162 | log(injectionStateStr(inj, 't1')); 163 | return inj.previous.call(myobj, i); 164 | }, 165 | t2: (inj, i) => { 166 | if (i >= 4) { 167 | inj.clear(); 168 | } else { 169 | inj.original.call(myobj, i); 170 | } 171 | assert( 172 | i <= 5, 173 | `t2 should be cleared after 5 (since 4 may be skipped by t3) (i: ${i})` 174 | ); 175 | return inj.previous.call(myobj, i); 176 | }, 177 | t3: (inj, i) => { 178 | if (i === 4) { 179 | inj.clear(); 180 | return inj.original.call(myobj, i); 181 | } else { 182 | assert(i < 4, `t3 should be cleared after 4 (i: ${i})`); 183 | return inj.previous.call(myobj, i); 184 | } 185 | }, 186 | t4: (inj, i) => { 187 | if (i === 5) { 188 | inj.original.call(myobj, i); 189 | inj.clear(); 190 | } 191 | assert(i <= 5, 't4 should be cleared after 5'); 192 | return inj.previous.call(myobj, i); 193 | }, 194 | t5: (inj, i) => { 195 | const val = inj.previous.call(myobj, i); 196 | if (i === 6) inj.clear(); 197 | assert(i <= 6, 't5 should be cleared after 6'); 198 | return val; 199 | }, 200 | }; 201 | const checkCall = i => { 202 | const val = myobj._addItems(i); 203 | assert(typeof val === 'string', `${val} should be a string (i: ${i})`); 204 | }; 205 | 206 | const names = Object.keys(injectionHandlers); 207 | 208 | for (const k of range(names.length+1)) { 209 | for (let trackerOrder of permutations(names, k)) { 210 | try { 211 | const trackers = Object.fromEntries( 212 | trackerOrder.map(name => [name, new InjectionTracker()]) 213 | ); 214 | for (const trackerName of trackerOrder) { 215 | const inj = trackers[trackerName].injectProperty( 216 | myobj, 217 | '_addItems', 218 | ii => injectionHandlers[trackerName](inj, ii) 219 | ); 220 | checkCall(-1); 221 | } 222 | for (const i of range(10)) { 223 | checkCall(i); 224 | } 225 | for (const tracker of Object.values(trackers)) { 226 | tracker.clearAll(); 227 | checkCall(-1); 228 | } 229 | assert( 230 | !('__injectionHistories' in myobj), 231 | 'should clear injection history' 232 | ); 233 | } catch (err) { 234 | log('trackers', trackerOrder.join(', ')); 235 | throw err; 236 | } 237 | } 238 | } 239 | }) 240 | ); 241 | -------------------------------------------------------------------------------- /tests/test-base.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Deminder 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | const baseTestRegex = /\/test-base\.js:\d+:\d+$/; 5 | const logFmt = new Intl.DateTimeFormat('en-US', { 6 | hour12: false, 7 | hour: '2-digit', 8 | minute: '2-digit', 9 | second: '2-digit', 10 | fractionalSecondDigits: 3, 11 | }); 12 | const fileRegex = /(.*)@(.*\/)(.*:\d+)(:\d+)$/; 13 | 14 | const GRAY = '\u001b[30m'; 15 | const RED = '\u001b[31m'; 16 | const GREEN = '\u001b[32m'; 17 | const BLUE = '\u001b[94m'; 18 | const CYAN = '\u001b[36m'; 19 | const RESET = '\u001b[0m'; 20 | function throwError(message) { 21 | const error = new Error(message); 22 | error.assertion = true; 23 | throw error; 24 | } 25 | let logLines = []; 26 | export const logOriginExcludes = new Map(); 27 | 28 | function testLog(...args) { 29 | const error = new Error(); 30 | const originLine = error.stack 31 | .split('\n') 32 | .slice(1) 33 | .find(line => !baseTestRegex.test(line)); 34 | if ( 35 | [...logOriginExcludes.values()].every( 36 | excludeRegex => !excludeRegex.test(originLine) 37 | ) 38 | ) { 39 | logLines.push([fileRegex.exec(originLine)[3], new Date(), ...args]); 40 | } 41 | } 42 | 43 | async function runIt(unit, [should, func]) { 44 | try { 45 | print(' ', should); 46 | logLines = []; 47 | logOriginExcludes.clear(); 48 | const ret = func(); 49 | return ret instanceof Promise ? await ret : ret; 50 | } catch (error) { 51 | print(`${RED}[TEST FAILED]${RESET}`); 52 | for (const [origin, date, ...line] of logLines) { 53 | print( 54 | `${GREEN}${origin} ${BLUE}${logFmt.format(date)}${RESET}:`, 55 | ...line 56 | ); 57 | } 58 | if (error.assertion) { 59 | print( 60 | `${RED}[assertion failed]${RESET} ${error.message}\n${error.stack 61 | .split('\n') 62 | .filter(line => !baseTestRegex.test(line)) 63 | .map(line => { 64 | line = line.trimStart(); 65 | if (line) { 66 | const [_, funcName, filePath, fileName, col] = 67 | fileRegex.exec(line); 68 | return `${RESET}${range(25) 69 | .map(c => funcName[c] ?? ' ') 70 | .join( 71 | '' 72 | )} @ ${CYAN}${filePath}${RESET}${fileName}${GRAY}${col}${RESET}`; 73 | } else { 74 | return line; 75 | } 76 | }) 77 | .join('\n')}` 78 | ); 79 | } else { 80 | console.error(`[failed] ${error.message}\n`, error.stack); 81 | } 82 | throw new Error(`${unit}: ${should}!`); 83 | } 84 | } 85 | 86 | export async function describe(unit, ...itEntires) { 87 | const origLog = globalThis.log; 88 | const origLogDebug = globalThis.logDebug; 89 | globalThis.testLog = testLog; 90 | globalThis.log = testLog; 91 | globalThis.logDebug = testLog; 92 | 93 | print(`${unit}:`); 94 | for await (const itResult of itEntires.map(itEntry => runIt(unit, itEntry))) { 95 | if (itResult !== undefined) { 96 | console.warn('Unexpected return value:', itResult); 97 | } 98 | } 99 | delete globalThis.testLog; 100 | globalThis.log = origLog; 101 | globalThis.logDebug = origLogDebug; 102 | } 103 | 104 | export function it(should, testFunc) { 105 | return [should, testFunc]; 106 | } 107 | 108 | export function assert(a, message = 'should be true') { 109 | if (!a) throwError(message); 110 | } 111 | 112 | export function assertEquals(a, b, message = 'should equal') { 113 | const strA = JSON.stringify(a); 114 | const strB = JSON.stringify(b); 115 | if (strA !== strB) throwError(`${message}\n${strA} != ${strB}`); 116 | } 117 | 118 | export function permutations(keys, k) { 119 | k ??= keys.length; 120 | return k <= 1 121 | ? keys.map(key => [key]) 122 | : keys.length > 1 123 | ? keys.flatMap((key, i) => 124 | permutations([...keys.slice(0, i), ...keys.slice(i + 1)], k - 1).map( 125 | comb => [key, ...comb] 126 | ) 127 | ) 128 | : [keys]; 129 | } 130 | 131 | export function product(aa, bb) { 132 | return aa.flatMap(a => bb.map(b => [a,b])) 133 | } 134 | 135 | export function range(end) { 136 | return [...Array(end).keys()]; 137 | } 138 | export function combinations(keys, k) { 139 | return permutations(keys, k).filter(p => 140 | [...p].sort().every((v, i) => v === p[i]) 141 | ); 142 | } 143 | -------------------------------------------------------------------------------- /tests/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # SPDX-FileCopyrightText: 2023 Deminder 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | cd $(dirname ${BASH_SOURCE}) 6 | 7 | for t in *.test.js 8 | do 9 | echo "-- TEST $t" 10 | OUTPUT=$(gjs -m $t 2>&1) 11 | [ $? = 1 ] && echo "$OUTPUT\n\n" && exit 1 12 | done 13 | echo "DONE" 14 | --------------------------------------------------------------------------------