diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5df8805 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*build*/* +!build/.: diff --git a/.travis.yml b/.travis.yml index fa359f2..ea36957 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,28 +1,34 @@ -language: python +language: c dist: trusty sudo: false +compiler: + - clang + - gcc + cache: apt: true pip: true - ccache: true addons: apt: packages: + - python-pip - cppcheck - - libwnck-dev + - libwnck-3-dev + - libglib2.0-dev before_install: - set -e - - pip install cpplint + - pip install --user cpplint + - ./lint.sh install: - - + - cd build + - DISPLAY=mock-for-later cmake -DCMAKE_C_FLAGS=-Werror -DCMAKE_INSTALL_PREFIX=~ .. + - make + - make install + - cpack script: - - cppcheck --enable=all --error-exitcode=1 -I src src - - cpplint --counting=toplevel --linelength=100 --recursive src - -after_success: - - + - xvfb-run make test diff --git a/CMakeLists.txt b/CMakeLists.txt index e69de29..c9dca13 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -0,0 +1,47 @@ +cmake_minimum_required (VERSION 2.8 FATAL_ERROR) + +project (xsuspender C) +set (PROJECT_VERSION 1.0) + +if (NOT CMAKE_BUILD_TYPE) + set (CMAKE_BUILD_TYPE Release) +endif () + +set (CMAKE_C_STANDARD 99) +set (CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -std=c99") # CMake<=3.0 + +set (CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Wall -Wextra") +set (CMAKE_C_FLAGS_DEBUG "${CMAKE_C_FLAGS_DEBUG} -O0 -DG_ENABLE_DEBUG") +set (CMAKE_C_FLAGS_RELEASE "${CMAKE_C_FLAGS_RELEASE} -O3") + +set (example_dir "share/doc/${PROJECT_NAME}/examples") + +add_definitions (-DPROJECT_NAME="${PROJECT_NAME}" + -DPROJECT_VERSION="${PROJECT_VERSION}" + -DEXAMPLE_CONF="${CMAKE_INSTALL_PREFIX}/${example_dir}/${PROJECT_NAME}.conf") + +install (FILES data/${PROJECT_NAME}.conf + DESTINATION ${example_dir}) +install (FILES data/${PROJECT_NAME}.desktop + DESTINATION etc/xdg/autostart) +install (FILES man/${PROJECT_NAME}.1 + DESTINATION man/man1) + +add_subdirectory (src) + +# Tests, if X is running +if (NOT $ENV{DISPLAY} EQUAL "") + set_property (GLOBAL PROPERTY CTEST_TARGETS_ADDED 1) # Avoid cruft CTest build targets + include (CTest) + add_test (TestHelp src/${PROJECT_NAME} --help) + set_tests_properties (TestHelp PROPERTIES PASS_REGULAR_EXPRESSION "Usage:\n ${PROJECT_NAME}.*") +endif () + +# `make package_source` +set (CPACK_PACKAGE_NAME "${PROJECT_NAME}") +set (CPACK_PACKAGE_EXECUTABLES "${PROJECT_NAME}") +set (CPACK_PACKAGE_VERSION "${PROJECT_VERSION}") +set (CPACK_SOURCE_PACKAGE_FILE_NAME "${CPACK_PACKAGE_NAME}-${CPACK_PACKAGE_VERSION}") +set (CPACK_SOURCE_IGNORE_FILES "/\\\\.git/;.*build.*;/\\\\..*") +set (CPACK_SOURCE_GENERATOR "TXZ") +include (CPack) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0e6e139 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,18 @@ +Hacking +======= + +Building +-------- + +To enable debugging closer to the metal, compile a debugging-enabled +binary by passing `-DCMAKE_BUILD_TYPE=Debug` on the cmake line. + +Running +------- + +Check for leaks with: + + G_SLICE=always-malloc \ + G_DEBUG=gc-friendly \ + G_MESSAGES_DEBUG=all \ + valgrind --leak-check=full --show-leak-kinds=definite xsuspender \ No newline at end of file diff --git a/CPPLINT.cfg b/CPPLINT.cfg new file mode 100644 index 0000000..f297385 --- /dev/null +++ b/CPPLINT.cfg @@ -0,0 +1,9 @@ +set noparent +linelength=100 +filter=-build/include_subdir +filter=-build/header_guard +filter=-legal +filter=-readability/casting +filter=-whitespace/parens +filter=-whitespace/operators +filter=-whitespace/braces diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5a8e332 --- /dev/null +++ b/LICENSE @@ -0,0 +1,14 @@ + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + Version 2, December 2004 + + Copyright (C) 2004 Sam Hocevar + + Everyone is permitted to copy and distribute verbatim or modified + copies of this license document, and changing it is allowed as long + as the name is changed. + + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. You just DO WHAT THE FUCK YOU WANT TO. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..8bd7c2f --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ +XSuspender +========== +[![Build Status](https://travis-ci.org/kernc/xsuspender.svg?branch=master)](https://travis-ci.org/kernc/xsuspender) + +Automatically suspend inactive X11 applications. + +Find a better maintained description here: https://kernc.github.io/xsuspender/ + +When an application window loses focus, XSuspender tries to match it to +one of the rules in its configuration. If a match is found, the +application is sent a SIGSTOP signal (preventing the process from obtaining +further CPU time). Upon windows regaining focus, the process is seamlessly +continued where it had left off. + +#### Advantages + +* **Reduce battery use (increase battery run-time).** + Make your laptop run on battery for as long as your mobile phone does, + using roughly the same technique. +* **Reduce interaction latency on low-end CPUs.** + With fewer clients requesting processing power, there's more of it to go + around where it's needed. +* **Reduce CPU fan noise.** + Save the tinnitus for old age. +* **Avoid apps plotting stuff behind your back.** + That Kali you're running in a VM is perfectly fine, but god + only knows what Microsoft Windos is doing. +* **Suspend processes using well-known Unix signals SIGSTOP & SIGCONT ...** + ... or custom shell scripts. Decades of portable operating systems + engineering at its finest. +* **Preconfigured for recent versions of popular software.** + Chromium, Firefox, JetBrains IDEs, qBittorrent, VirtualBox ... + +#### Quirks + +* Quirky. See [Notes] below. +* May prevent suspended windows from redrawing until re-gaining focus. +* May make your web downloads stall and your in-browser media + playback stop if you configure it thus. +* Prevents pasting from clipboard while the selection source process + is suspended + ([explanation](https://unix.stackexchange.com/questions/316715/xclip-works-differently-in-interactive-and-non-interactive-shells/316890#316890)). +* Relies on windows having their `_NET_WM_PID` hint set correctly. +* Won't work in remote X sessions. +* Won't work with Wayland. + + +Installation +------------ + +#### From Source + +```bash +# Install build dependencies, namely GLib and Libwnck +sudo apt install libglib2.0-dev \ + libwnck-3-dev \ + make cmake gcc pkg-config +``` + +```bash +# Fetch a copy of the source code +git clone https://github.com/kernc/xsuspender +cd xsuspender + +# Move to build directory for an out-of-tree build +cd build + +# Configure and make +cmake -DCMAKE_INSTALL_PREFIX=/usr/local .. +make +make test + +# Install within chosen prefix +sudo make install +``` + +Usage +----- +For brief usage instructions, run: + +```bash +xsuspender --help +``` + +#### Configuration debugging + +To have it print verbose debug messages about what it is doing, run the +program with environmental variable `G_MESSAGES_DEBUG=all` set: + + G_MESSAGES_DEBUG=all xsuspender + +This is _strongly recommended_ to confirm your customized configuration +rules indeed work as you expect. + +If xsuspender is auto run by your X session manager, you might find clues +to its unexpected behavior in _~/.xsession-errors_. + +Notes +----- +[Notes]: #notes + +* Processes that take a long time to shut down after their window already + disappears may be stopped in the middle of their termination routines. + Avoid with reasonably generous `suspend_delay`. +* Windows that minimize to system tray need to be awaken frequently to + respond to click events in a seamless manner. +* Don't configure xsuspender for software you want to keep continuously alive + in the background, such as music players, daemons, IM clients ... If you + frequently stream music from YouTube, you might give [Musictube] a try. + +[Musictube]: https://flavio.tordini.org/musictube diff --git a/build/.: b/build/.: new file mode 100644 index 0000000..cae10d7 --- /dev/null +++ b/build/.: @@ -0,0 +1 @@ +I'll just leave this here. diff --git a/data/xsuspender.conf b/data/xsuspender.conf new file mode 100644 index 0000000..3752d87 --- /dev/null +++ b/data/xsuspender.conf @@ -0,0 +1,99 @@ +# Configuration file for xsuspender. +# +# Sections represent rules windows are matched with. +# Find full documentation in xsuspender(1) manual. +# +# [Example] +# # Window matching rules. Some can be left blank. +# # Intersection of non-blanks applies. +# match_wm_class_contains = SomeApplication +# match_wm_class_group_contains = ... +# match_wm_name_contains = Part of Some Window Title +# +# # Seconds to wait before suspending after window loses focus. +# suspend_delay = 10 +# +# # Resume suspended process every this many seconds … +# resume_every = 50 +# +# # … for this many seconds. +# resume_for = 5 +# +# # Before suspending, execute this shell script. If it fails, +# # abort suspension. +# exec_suspend = echo "suspending window $XID of process $PID" +# +# # Before resuming, execute this shell script. Resume the +# # process regardless script failure. +# exec_resume = echo resuming ... +# +# # Whether to send SIGSTOP / SIGCONT signals or not. If false, +# # just the exec_* scripts are run. +# send_signals = true +# +# # Also suspend descendant processes that match this regex. +# suspend_subtree_pattern = . +# +# # Whether to apply the rule only when on battery power. +# only_on_battery = true +# +# # Whether to auto-apply rules when switching to battery +# # power even if the window(s) didn't just lose focus. +# auto_suspend_on_battery = true +# +# +# Values set in the Default section are inherited and overridden +# by other sections below. + +[Default] +suspend_delay = 5 +resume_every = 50 +resume_for = 5 +send_signals = true +only_on_battery = true +auto_suspend_on_battery = true + +# Preset configuration for some common software. + +[Chromium] +suspend_delay = 10 +match_wm_class_contains = chromium +suspend_subtree_pattern = chromium + +[Firefox] +suspend_delay = 10 +match_wm_class_contains = Navigator +match_wm_class_group_contains = Firefox +suspend_subtree_pattern = \/(firefox|plugin-container) + +[JetBrains IDEs] +match_wm_class_group_contains = jetbrains- + +[VirtualBox] +match_wm_class_contains = VirtualBox +match_wm_name_contains = - Oracle VM +exec_suspend = VBoxManage controlvm "$(ps -o args= -q $PID | sed -E 's/.*--startvm ([^ ]+).*/\1/')" pause +exec_resume = VBoxManage controlvm "$(ps -o args= -q $PID | sed -E 's/.*--startvm ([^ ]+).*/\1/')" resume +send_signals = false +resume_every = 0 +only_on_battery = false + +[qBittorrent] +match_wm_class_contains = qbittorrent +resume_every = 5 +resume_for = 1 +suspend_delay = 60 + +#[MyApplication] +#match_wm_name_contains = +#match_wm_class_contains = +#match_wm_class_group_contains = +#suspend_delay = 10 +#resume_every = 50 +#resume_for = 5 +#exec_suspend = +#exec_resume = +#suspend_subtree_pattern = +#send_signals = true +#only_on_battery = true +#auto_suspend_on_battery = true diff --git a/data/xsuspender.desktop b/data/xsuspender.desktop new file mode 100644 index 0000000..0156cbc --- /dev/null +++ b/data/xsuspender.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=XSuspender +Comment=Automatically suspend inactive windows +TryExec=xsuspender +Exec=xsuspender +Icon=preferences-system-windows +NoDisplay=false +Hidden=true diff --git a/doc/xsuspender.1 b/doc/xsuspender.1 new file mode 100644 index 0000000..c0edc9e --- /dev/null +++ b/doc/xsuspender.1 @@ -0,0 +1,327 @@ +.\" Macros from groff an-ext.tmac file for portability +.de TQ +. ns +. TP \\$1\" no doublequotes around argument! +.. +.de UR +. ds m1 \\$1\" +. nh +. if \\n(mH \{\ +. do ev URL-div +. do di URL-div +. \} +.. +.de UE +. ie \\n(mH \{\ +. br +. di +. ev +. +. ie \\n(dn \{\ +. do HTML-NS "" +. do chop URL-div +. do URL-div +\c +. do HTML-NS +. \} +. el \ +. do HTML-NS "\\*(m1" +\&\\$*\" +. \} +. el \ +\\*(la\\*(m1\\*(ra\\$*\" +. +. hy \\n(HY +.. +.de EX +. do ds mF \\n[.fam] +. nr mE \\n(.f +. nf +. nh +. do fam C +. ft CW +.. +.de EE +. do fam \\*(mF +. ft \\n(mE +. fi +. hy \\n(HY +.. +. +. +.TH XSUSPENDER 1 2017-12-13 +.SH NAME +xsuspender \- auto-suspend inactive X11 applications +.SH SYNOPSIS +.TP +.B xsuspender +.SH DESCRIPTION +Modern desktop GUI applications may use up significant system resources, +such as CPU time, just while idly running and even when unused. +Respecting expressive configuration, XSuspender moderates such +applications to suspend all activity shortly after their windows are unfocused. +by sending them Unix signal +.B SIGSTOP +(or executing a custom script; see +.B exec_suspend +key in section +.B CONFIGURATION +below). +When the application window regains focus, XSuspender sends it signal +.B SIGCONT\fR, +and it resumes execution exactly where it had left off. +.PP +This may result in significant reduction in CPU use which +prolongs battery run-time (5\-500%; subject to user's computing habits), +reduces CPU fan noise, and +prevents resource-hogging applications from plotting stuff +behind your back at least when you are not actively using them. +.PP +The method also has its share of drawbacks. See section +.B BUGS +below. +.SH OPTIONS +The program is mainly run without arguments. +.TP +.BR \-h ", " \-\-help +Print short help and exit. +.SH ENVIRONMENT +.TP +.B G_MESSAGES_DEBUG +Set +.B G_MESSAGES_DEBUG\fR=\fIall\fR +to have the program report debug messages of its activity +to standard error. +.TP +.B XDG_CONFIG_HOME +If set, the directory where +.I xsuspender.conf +configuration file can be found. +If missing, this location defaults to \fI~/.config\fR. +.SH FILES +.TP +.I ~/.config/xsuspender.conf +Configuration file read by the program. +.TP +.I $XDG_CONFIG_HOME/xsuspender.conf +Alternative configuration file when the variable is set. +.TP +.I share/xsuspender/xsuspender.conf.example +Example configuration file shipped with the program. +.TP +.I etc/xdg/autostart/xsuspender.desktop +A desktop session auto-start launcher. +Disabled by default. +To enable, copy it into +.I .config/autostart +directory (or +.I $XDG_CONFIG_HOME/autostart +where applicable) and edit it, setting \fIHidden=false\fR. +Alternatively, you may be able to enable it via the +session application auto-start manager. +.SH CONFIGURATION +Configuration key\-value file is in INI format. Notice: +.RS 2 +.IP \[bu] 2 +file must be encoded in UTF-8, +.IP \[bu] 2 +comments follow '#' character, +.IP \[bu] 2 +section and key names are case-sensitive. +.RE +.PP +The file should contain a +.B [Default] +section which all other sections inherit and override on per-key basis. +.PP +Each non-\fB[Default]\fR section constitutes a rule that +XSuspender will try to match and follow. +Sections can define the the following keys (listed with built-in +default values): +.TP +.B match_wm_name_contains +.TQ +.B match_wm_class_contains +.TQ +.B match_wm_class_group_contains +These keys order xsuspender to apply the rule only to windows +that contain the value string verbatim in their +.I WM_NAME +and +.I WM_CLASS +window manager hints. +When multiple matching keys are specified, their +logical conjunction (intersection) applies. +To find applicable values, run: +.IP +.in +4 +.nf +xprop -notype WM_NAME WM_CLASS +.fi +.in +.IP +and click on the window of interest. Class group is the +second string in the +.I WM_CLASS +atom. +.TP +.BR suspend_delay \ =\ 10 +Integer seconds to wait after the window loses focus +before it is suspended. +Setting a reasonable value avoids suspending processes too early during +rapid task switching. +If you set this value too low, upon process termination, +the process may stall in its termination routine if it hasn't +been able to finish in time. +Since we're unable to detect this, best give your processes ample time. +.TP +.BR resume_every \ =\ 50 +Resume the suspended process every this many integer seconds +to have it do (some of) its backlog of work. +.TP +.BR resume_for \ =\ 5 +Resume suspended process only for this long before suspending it again. +.TP +.BR only_on_battery \ =\ true +Apply the rule only when on battery power. You may wish to set this to +\fIfalse\fR for hopelessly annoying processes such as virtual machines +running Windos. +.TP +.BR auto_suspend_on_battery \ =\ true +If true, automatically apply this rule when on batter power, even if +the matching window didn't just lose focus. +.TP +.BR suspend_subtree_pattern +Extended regular expression. +If provided, when XSuspender suspends a process, it will also suspend +all its descendant processes (children, grandchildren etc.) whose +command line string matches the pattern. +Set to \fI.\fR to match and kill all descendants. +This setting only applies when +.B send_signals +is also true. +.TP +.BR exec_suspend +.TQ +.BR exec_resume +Before suspending / resuming the process, +execute the string as a command line shell script. +If the script execution is successful (exit status 0), proceed +with process suspension, otherwise abort it. +(Process resumption is never aborted.) +.IP +The executed script gets passed via the environment the following +variables: +.I PID +(process id), +.I XID +(X11 window id of the triggering window), and +.I WM_NAME +(window title). +.IP +You can use these keys to control suspension of your processes +or disjoint components thereof +in a custom way. In this case, you may also find +.B send_signals +key to be of use. +.TP +.BR send_signals \ =\ true +If true, XSuspender kills the matching process with +SIGSTOP / SIGCONT Unix signals. +If false, XSuspender sends no signals of its own, deferring to +your +.B exec_suspend +and +.B exec_resume +scripts to do the work. +.SH BUGS +The following limitations and quirks are known: +.IP \[bu] 2 +Don't configure XSuspender for software you want to keep continuously alive +in the background, such as music players and daemons. +If you suspend IM clients, wake them up frequently (configuration key +.B resume_every\fR) +so they can process their queued network events. +If you like to stream music from YouTube or similar, you might +want to exempt your browser from suspension, +or consider using a lighter-weight native client, such as Musictube. +.IP \[bu] 2 +Windows that minimize to system tray, such as torrent and IM clients, +need to be resumed frequently to respond to tray icon click events +in a seamless manner. +.IP \[bu] 2 +Pasting from clipboard is prevented while the selection source process +is suspended. +Mitigate by using a clipboard manager that takes ownership of +the selection. +.IP \[bu] 2 +Suspended windows might not redraw until regaining focus. +If something covers them, expect visual artifacts. +.IP \[bu] 2 +Mouse-wheel scrolling might not work in suspended windows. +They require keyboard input focus to resume. +.IP \[bu] 2 +Processes that take a long time to shut down after their window already +disappears may be stopped in the middle of their termination routines. +Avoid with reasonably generous `suspend_delay`. +.IP \[bu] 2 +XSuspender won't work in remote X sessions. +.IP \[bu] 2 +XSuspender won't work with Wayland. +.PP +Please raise any further bugs and ideas on the project's +.UR https://github.com/kernc/xsuspender/issues +GitHub issue tracker +.UE . +.SH EXAMPLE +Example configuration section for VirtualBox: +.PP +.in +4 +.EX +# This is a comment. + +[Default] +suspend_delay = 10 +resume_every = 50 +resume_for = 5 +only_on_battery = true +auto_suspend_on_battery = true +send_signals = true + +# Rule name is an alphanumeric string. +# Rule VirtualBox inherits from Default rule + +[VirtualBox] +match_wm_class_contains = VirtualBox + +# VirtualBox suspension is managed by VBoxManage utility. +send_signals = false + +# We get the VM UUID via its process command line. +# NOTE: Unlike in this example, the whole script should be +# on a single line. +exec_suspend = VBoxManage controlvm "$(ps -o args= -q $PID | \\ + sed -E 's/.*--startvm ([a-f0-9-]+).*/\\1/')" pause +exec_resume = VBoxManage controlvm "$(ps -o args= -q $PID | \\ + sed -E 's/.*--startvm ([a-f0-9-]+).*/\\1/')" resume +.EE +.in +.PP +Find further examples in example configuration file +accompanying this distribution +(see section +.B FILES +above). +.PP +To debug your configuration rules, run: +.PP +.in +4 +.nf +G_MESSAGES_DEBUG=all xsuspender +.fi +.in +.SH SEE ALSO +.BR kill (1), +.BR xprop (1), +.BR regex (7), +.BR signal (7) diff --git a/lint.sh b/lint.sh new file mode 100755 index 0000000..bd0e637 --- /dev/null +++ b/lint.sh @@ -0,0 +1,19 @@ +#!/bin/sh + +set -e + +ROOT="$(dirname "$0")" +PATH="$PATH:$HOME/.local/bin" + +error () { echo "ERROR: $@" >&2; exit 1; } + +cppcheck --enable=all --error-exitcode=1 \ + --suppress=unusedFunction:"$ROOT/src/macros.h" "$ROOT"/src/*.[ch] + +cpplint --counting=detailed --recursive "$ROOT"/src + +grep -RF $'\t' "$ROOT"/src && + error 'Tabs found. Use spaces.' || echo 'Whitespace OK' + +grep -RP '[^[:cntrl:][:print:]]' "$ROOT"/src && + error 'No emojis.' || echo 'Emojis OK' diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt new file mode 100644 index 0000000..a00da29 --- /dev/null +++ b/src/CMakeLists.txt @@ -0,0 +1,19 @@ +find_package (PkgConfig REQUIRED) + +pkg_check_modules (GLIB REQUIRED glib-2.0) +pkg_check_modules (WNCK REQUIRED libwnck-3.0) + +add_definitions (-DWNCK_I_KNOW_THIS_IS_UNSTABLE + -D_POSIX_SOURCE) + +add_compile_options (${GLIB_CFLAGS} ${WNCK_CFLAGS}) + +file (GLOB SOURCES "*.[ch]") +add_executable (${PROJECT_NAME} ${SOURCES}) + +# pkg-config recipe for libwnck may be a bit overzealos; trim linked libs to essentials +set (CMAKE_EXE_LINKER_FLAGS "-Wl,--as-needed -Wl,--no-undefined") + +target_link_libraries (${PROJECT_NAME} PRIVATE ${GLIB_LDFLAGS} ${WNCK_LDFLAGS}) + +install (TARGETS ${PROJECT_NAME} DESTINATION bin) diff --git a/src/config.c b/src/config.c index e69de29..e08e06d 100644 --- a/src/config.c +++ b/src/config.c @@ -0,0 +1,272 @@ +#include "config.h" + +#include + +#include + +#include "rule.h" +#include "macros.h" + + +static +gboolean error_encountered = FALSE; + + +static +gboolean +is_error (GError **err) +{ + if (g_error_matches (*err, G_KEY_FILE_ERROR, G_KEY_FILE_ERROR_INVALID_VALUE)) { + g_warning ("Error parsing config: %s", (*err)->message); + error_encountered = TRUE; + } + if (*err) { + g_error_free (*err); + *err = NULL; + return TRUE; + } + return FALSE; +} + + +static +char** +parse_command (GKeyFile *file, + char *section, + char *key, + GError **err) +{ + g_autofree char *value = g_key_file_get_value (file, section, key, err); + if (! value) + return NULL; + + char **argv = NULL; + // Empty command is same as no command + value = g_strstrip (value); + if (g_strcmp0 (value, "") != 0) { + // Pass everything to /bin/sh as this is the most convenient for scripting + char *args[4] = {"sh", "-c", value, NULL}; + argv = g_strdupv (args); + } + return argv; +} + + +static +void +reassign_strv (char ***existing, + char **replacement) +{ + // This can be simple because the rest is done in parse_command() + g_strfreev (*existing); + *existing = replacement; +} + + +static +void +reassign_str (char **existing, + char *replacement) +{ + // If the key was missing, GError should catch it. + // Perhaps an empty one, but replacement is definitely a string. + g_assert (replacement != NULL); + + // Free the previous value + g_free (*existing); + *existing = NULL; + + // If replacement is a non-empty string, use it + if (g_strcmp0 (replacement, "") != 0) + *existing = replacement; + else + // Otherwise, we have no use for it + g_free (replacement); +} + + +// Remove once glib-2.44+ is ubiquitous +#if GLIB_MINOR_VERSION < 44 +static inline +gboolean +g_strv_contains (const gchar *const *strv, + const gchar *str) +{ + for (; *strv; strv++) + if (g_strcmp0 (str, *strv) == 0) + return TRUE; + return FALSE; +} +#endif + + +static +void +read_section (GKeyFile *file, + char *section, + Rule *rule) +{ + g_autoptr (GError) err = NULL; + + int val; + + val = g_key_file_get_boolean (file, section, CONFIG_KEY_ONLY_ON_BATTERY, &err); + if (! is_error (&err)) rule->only_on_battery = (gboolean) val; + + val = g_key_file_get_boolean (file, section, CONFIG_KEY_AUTO_ON_BATTERY, &err); + if (! is_error (&err)) rule->auto_on_battery = (gboolean) val; + + val = g_key_file_get_boolean (file, section, CONFIG_KEY_SEND_SIGNALS, &err); + if (! is_error (&err)) rule->send_signals = (gboolean) val; + + val = g_key_file_get_integer (file, section, CONFIG_KEY_SUSPEND_DELAY, &err); + if (! is_error (&err)) rule->delay = (guint16) CLAMP (val, 1, G_MAXUINT16); + + val = g_key_file_get_integer (file, section, CONFIG_KEY_RESUME_EVERY, &err); + if (! is_error (&err)) rule->resume_every = (guint16) (val ? CLAMP (val, 1, G_MAXUINT16) : 0); + + val = g_key_file_get_integer (file, section, CONFIG_KEY_RESUME_FOR, &err); + if (! is_error (&err)) rule->resume_for = (guint16) CLAMP (val, 1, G_MAXUINT16); + + char *str; + + str = g_key_file_get_value (file, section, CONFIG_KEY_WM_CLASS_CONTAINS, &err); + if (! is_error (&err)) reassign_str (&rule->needle_wm_class, str); + + str = g_key_file_get_value (file, section, CONFIG_KEY_WM_CLASS_GROUP_CONTAINS, &err); + if (! is_error (&err)) reassign_str (&rule->needle_wm_class_group, str); + + str = g_key_file_get_value (file, section, CONFIG_KEY_WM_NAME_CONTAINS, &err); + if (! is_error (&err)) reassign_str (&rule->needle_wm_name, str); + + str = g_key_file_get_value (file, section, CONFIG_KEY_SUBTREE_PATTERN, &err); + if (! is_error (&err)) reassign_str (&rule->subtree_pattern, str); + + char **argv; + + argv = parse_command (file, section, CONFIG_KEY_EXEC_SUSPEND, &err); + if (! is_error (&err)) reassign_strv (&rule->exec_suspend, argv); + + argv = parse_command (file, section, CONFIG_KEY_EXEC_RESUME, &err); + if (! is_error (&err)) reassign_strv (&rule->exec_resume, argv); + + g_assert (err == NULL); // Already freed + + // Ensure all configuration keys are valid + static const char* VALID_KEYS[] = CONFIG_VALID_KEYS; + g_auto (GStrv) keys = g_key_file_get_keys (file, section, NULL, NULL); + for (int i = 0; keys[i]; ++i) { + if (! g_strv_contains (VALID_KEYS, keys[i])) { + g_warning ("Invalid key in section '%s': %s", section, keys[i]); + error_encountered = TRUE; + } + } + + // For non-Default sections, ensure at least one window-matching needle is specified + if (g_strcmp0 (section, CONFIG_DEFAULT_SECTION) != 0 && + ! rule->needle_wm_name && + ! rule->needle_wm_class && + ! rule->needle_wm_class_group) { + g_warning ("Invalid rule '%s' matches all windows", section); + error_encountered = TRUE; + } +} + + +static +void +debug_print_rule (Rule *rule) +{ + g_debug ("\n" + "needle_wm_class = %s\n" + "needle_wm_class_group = %s\n" + "needle_wm_name = %s\n" + "delay = %d\n" + "resume_every = %d\n" + "resume_for = %d\n" + "only_on_battery = %d\n" + "send_signals = %d\n" + "subtree_pattern = %s\n" + "exec_suspend = %s\n" + "exec_resume = %s\n", + rule->needle_wm_class, + rule->needle_wm_class_group, + rule->needle_wm_name, + rule->delay, + rule->resume_every, + rule->resume_for, + rule->only_on_battery, + rule->send_signals, + rule->subtree_pattern, + (rule->exec_suspend ? rule->exec_suspend[2] : NULL), + (rule->exec_resume ? rule->exec_resume[2] : NULL) + ); +} + + +Rule ** +parse_config () +{ + Rule defaults = { + .needle_wm_name = NULL, + .needle_wm_class = NULL, + .needle_wm_class_group = NULL, + + .exec_suspend = NULL, + .exec_resume = NULL, + + .subtree_pattern = NULL, + + .delay = 10, + .resume_every = 50, + .resume_for = 5, + + .send_signals = TRUE, + .only_on_battery = TRUE, + .auto_on_battery = TRUE, + }; + + g_autoptr (GKeyFile) file = g_key_file_new (); + g_autoptr (GError) err = NULL; + + g_autofree char *path = g_build_path ("/", g_get_user_config_dir (), CONFIG_FILE_NAME, NULL); + g_key_file_load_from_file (file, path, G_KEY_FILE_NONE, &err); + + if (err) + g_critical ("Cannot read configuration file '%s': %s", path, err->message); + + // Process Default section + gboolean has_default_section = g_key_file_has_group (file, CONFIG_DEFAULT_SECTION); + if (has_default_section) + read_section (file, CONFIG_DEFAULT_SECTION, &defaults); + + // Read all other sections (rules) + gsize n_sections = 0; + g_auto (GStrv) sections = g_key_file_get_groups (file, &n_sections); + n_sections -= has_default_section; + + if (n_sections <= 0) + g_critical ("No configuration rules found. Nothing to do. Exiting."); + + Rule **rules = g_malloc0_n (n_sections + 1, sizeof (Rule*)), + **ptr = rules; + + for (int i = 0; sections[i]; ++i) { + // Skip Default section; it was already handled above + if (g_strcmp0 (sections[i], CONFIG_DEFAULT_SECTION) == 0) + continue; + + Rule *rule = xsus_rule_copy (&defaults); + read_section (file, sections[i], rule); + *ptr++ = rule; + } + + // Debug dump rules in effect + for (int i = 0; rules[i]; ++i) + debug_print_rule (rules[i]); + + if (error_encountered) + g_critical ("Errors encountered while parsing config. Abort."); + + return rules; +} diff --git a/src/config.h b/src/config.h index e69de29..0984cc7 100644 --- a/src/config.h +++ b/src/config.h @@ -0,0 +1,42 @@ +#ifndef XSUSPENDER_PARSE_CONFIG_H +#define XSUSPENDER_PARSE_CONFIG_H + +#include "xsuspender.h" +#include "rule.h" + +#define CONFIG_FILE_NAME (PROJECT_NAME ".conf") + +#define CONFIG_DEFAULT_SECTION "Default" + +#define CONFIG_KEY_EXEC_SUSPEND "exec_suspend" +#define CONFIG_KEY_EXEC_RESUME "exec_resume" +#define CONFIG_KEY_SUSPEND_DELAY "suspend_delay" +#define CONFIG_KEY_RESUME_EVERY "resume_every" +#define CONFIG_KEY_RESUME_FOR "resume_for" +#define CONFIG_KEY_ONLY_ON_BATTERY "only_on_battery" +#define CONFIG_KEY_AUTO_ON_BATTERY "auto_suspend_on_battery" +#define CONFIG_KEY_SEND_SIGNALS "send_signals" +#define CONFIG_KEY_SUBTREE_PATTERN "suspend_subtree_pattern" +#define CONFIG_KEY_WM_NAME_CONTAINS "match_wm_name_contains" +#define CONFIG_KEY_WM_CLASS_CONTAINS "match_wm_class_contains" +#define CONFIG_KEY_WM_CLASS_GROUP_CONTAINS "match_wm_class_group_contains" + +#define CONFIG_VALID_KEYS {CONFIG_KEY_ONLY_ON_BATTERY, \ + CONFIG_KEY_AUTO_ON_BATTERY, \ + CONFIG_KEY_SEND_SIGNALS, \ + CONFIG_KEY_SUBTREE_PATTERN, \ + CONFIG_KEY_SUSPEND_DELAY, \ + CONFIG_KEY_RESUME_EVERY, \ + CONFIG_KEY_RESUME_FOR, \ + CONFIG_KEY_WM_CLASS_CONTAINS, \ + CONFIG_KEY_WM_CLASS_GROUP_CONTAINS, \ + CONFIG_KEY_WM_NAME_CONTAINS, \ + CONFIG_KEY_EXEC_SUSPEND, \ + CONFIG_KEY_EXEC_RESUME, \ + NULL} + + +Rule** parse_config (); + + +#endif // XSUSPENDER_PARSE_CONFIG_H diff --git a/src/entry.c b/src/entry.c new file mode 100644 index 0000000..183e7a7 --- /dev/null +++ b/src/entry.c @@ -0,0 +1,63 @@ +#include "entry.h" + +#include +#include + + +WindowEntry* +xsus_window_entry_new (WnckWindow *window, + Rule *rule) +{ + WindowEntry *entry = g_malloc (sizeof (WindowEntry)); + entry->rule = rule; + entry->pid = wnck_window_get_pid (window); + entry->xid = wnck_window_get_xid (window); + entry->wm_name = g_strdup (wnck_window_get_name (window)); + return entry; +} + + +WindowEntry* +xsus_window_entry_copy (WindowEntry *entry) +{ + WindowEntry *copy = g_memdup (entry, sizeof (WindowEntry)); + copy->wm_name = g_strdup (copy->wm_name); + return copy; +} + + +void +xsus_window_entry_free (WindowEntry *entry) +{ + if (! entry) + return; + g_free (entry->wm_name); + g_free (entry); +} + + +inline +WindowEntry* +xsus_entry_find_for_window_rule (WnckWindow *window, + Rule *rule, + GSList *list) +{ + // If suspending by signals, find entry by PID ... + if (rule->send_signals) { + pid_t pid = wnck_window_get_pid (window); + for (; list; list = list->next) { + WindowEntry *entry = list->data; + if (entry->pid == pid) + return entry; + } + } else { + // ... else find it by XID + XID xid = wnck_window_get_xid (window); + for (; list; list = list->next) { + WindowEntry *entry = list->data; + if (entry->xid == xid) + return entry; + } + } + return NULL; +} diff --git a/src/entry.h b/src/entry.h new file mode 100644 index 0000000..56b4b64 --- /dev/null +++ b/src/entry.h @@ -0,0 +1,36 @@ +#ifndef XSUSPENDER_ENTRY_H +#define XSUSPENDER_ENTRY_H + +#include + +#include +#include + +#include "rule.h" + + +typedef gulong XID; + + +// Window entry in the hash tables +typedef struct WindowEntry { + Rule *rule; + char *wm_name; // Window title + + // The X ID and PID of the window that triggered the rule + XID xid; + pid_t pid; + + // Timestamp of the next (and last) window suspension by this rule. + time_t suspend_timestamp; +} WindowEntry; + + +WindowEntry* xsus_window_entry_new (WnckWindow *window, Rule *rule); +WindowEntry* xsus_window_entry_copy (WindowEntry *entry); +void xsus_window_entry_free (WindowEntry *entry); + +WindowEntry* xsus_entry_find_for_window_rule (WnckWindow *window, Rule *rule, GSList *where); + + +#endif // XSUSPENDER_ENTRY_H diff --git a/src/events.c b/src/events.c new file mode 100644 index 0000000..5923234 --- /dev/null +++ b/src/events.c @@ -0,0 +1,236 @@ +#include "events.h" + +#include + +#include +#include + +#include "entry.h" + + +void +xsus_init_event_handlers () +{ + WnckScreen *screen = wnck_screen_get_default (); + + g_signal_connect (screen, "active-window-changed", + G_CALLBACK (on_active_window_changed), NULL); + + // Periodically run handler tending to the suspension queue + g_timeout_add_seconds_full ( + G_PRIORITY_LOW, SUSPEND_PENDING_INTERVAL, on_suspend_pending_windows, NULL, NULL); + + // Periodically resume windows for a while so they get "up to date" + g_timeout_add_seconds_full ( + G_PRIORITY_LOW, PERIODIC_RESUME_INTERVAL, on_periodic_window_wake_up, NULL, NULL); + + // Periodically check if we're on battery power + g_timeout_add_seconds_full ( + G_PRIORITY_LOW, CHECK_BATTERY_INTERVAL, on_check_battery_powered, NULL, NULL); +} + + +static +WnckWindow* +get_main_window (WnckWindow *window) +{ + if (! WNCK_IS_WINDOW (window)) + return NULL; + + // Resolve transient (dependent, dialog) windows + WnckWindow *parent; + while ((parent = wnck_window_get_transient (window))) + window = parent; + + // Ensure window is of correct type + WnckWindowType type = wnck_window_get_window_type (window); + if (type == WNCK_WINDOW_NORMAL || + type == WNCK_WINDOW_DIALOG) + return window; + + return NULL; +} + + +static inline +gboolean +windows_are_same_process (WnckWindow *w1, + WnckWindow *w2) +{ + // Consider windows to be of the same process when they + // are one and the same window, + if (w1 == w2) + return TRUE; + + // Or when they have the same PID, map to the same rule, + // and the rule says that signals should be sent. + Rule *rule; + return (WNCK_IS_WINDOW (w1) && + WNCK_IS_WINDOW (w2) && + wnck_window_get_pid (w1) == wnck_window_get_pid (w2) && + (rule = xsus_window_get_rule (w1)) && + rule->send_signals && + rule == xsus_window_get_rule (w2)); +} + + +void +on_active_window_changed (WnckScreen *screen, + WnckWindow *prev_active_window) +{ + WnckWindow *active_window = wnck_screen_get_active_window (screen); + + active_window = get_main_window (active_window); + prev_active_window = get_main_window (prev_active_window); + + // Main windows are one and the same; do nothing + if (windows_are_same_process (active_window, prev_active_window)) + return; + + // Resume the active window if it was (to be) suspended + if (active_window) + xsus_window_resume (active_window); + + // Maybe suspend previously active window + if (prev_active_window) + xsus_window_suspend (prev_active_window); +} + + +static inline +gboolean +window_exists (WindowEntry *entry) +{ + WnckWindow *window = wnck_window_get (entry->xid); + return window && wnck_window_get_pid (window) == entry->pid; +} + + +int +on_suspend_pending_windows () +{ + time_t now = time (NULL); + GSList *l = queued_entries; + while (l) { + GSList *next = l->next; + WindowEntry *entry = l->data; + + if (now >= entry->suspend_timestamp) { + queued_entries = g_slist_delete_link (queued_entries, l); + + // Follow through with suspension only if window is still alive + if (window_exists (entry)) + xsus_signal_stop (entry); + } + l = next; + } + return TRUE; +} + + +int +on_periodic_window_wake_up () +{ + time_t now = time (NULL); + GSList *l = suspended_entries; + while (l) { + WindowEntry *entry = l->data; + l = l->next; + + // Is it time to resume the process? + if (entry->rule->resume_every && + now - entry->suspend_timestamp >= entry->rule->resume_every) { + g_debug ("Periodic awaking %#lx (%d) for %d seconds", + entry->xid, entry->pid, entry->rule->resume_for); + + // Re-schedule suspension if window is still alive + if (window_exists (entry)) { + // Make a copy because continuing below frees the entry + WindowEntry *copy = xsus_window_entry_copy (entry); + xsus_window_entry_enqueue (copy, entry->rule->resume_for); + } + + xsus_signal_continue (entry); + } + } + return TRUE; +} + + +static +void +iterate_windows_kill_matching () +{ + for (GList *w = wnck_screen_get_windows (wnck_screen_get_default ()); w ; w = w->next) { + WnckWindow *window = w->data; + + // Skip transient windows and windows of incorrect type + if (window != get_main_window (window)) + continue; + + Rule *rule = xsus_window_get_rule (window); + + // Skip non-matching windows + if (! rule) + continue; + + // Skip currently focused window + if (wnck_window_is_active (window)) + continue; + + // On battery, auto-suspend windows that allow it + if (is_battery_powered && rule->auto_on_battery) { + // Do nothing if we're already keeping track of this window + if (xsus_entry_find_for_window_rule (window, rule, queued_entries) || + xsus_entry_find_for_window_rule (window, rule, suspended_entries)) + continue; + + // Otherwise, schedule the window for suspension shortly + WindowEntry *entry = xsus_window_entry_new (window, rule); + xsus_window_entry_enqueue (entry, rule->resume_for); + + // On AC, don't auto-resume windows that want to be suspended also + // when on AC, e.g. VirtualBox with Windos + } else if (!is_battery_powered && rule->only_on_battery) { + xsus_window_resume (window); + } + } +} + + +static +gboolean +is_on_ac_power () +{ +#ifdef __linux__ + int exit_status = -1; + // Read AC power state. Should work in most cases. See: https://bugs.debian.org/473629 + char *argv[] = {"sh", "-c", "grep -q 1 /sys/class/power_supply/*/online", NULL}; + g_spawn_sync (NULL, argv, NULL, + G_SPAWN_SEARCH_PATH | (IS_DEBUG ? G_SPAWN_DEFAULT : G_SPAWN_STDERR_TO_DEV_NULL), + NULL, NULL, NULL, NULL, &exit_status, NULL); + gboolean is_ac_power = exit_status == 0; + return is_ac_power; +#else + #warning "No battery / AC status support for your platform." + #warning "Defaulting to as if 'always on battery' behavior. Patches welcome!" + return FALSE; +#endif +} + + +int +on_check_battery_powered () +{ + gboolean previous_state = is_battery_powered; + is_battery_powered = ! is_on_ac_power (); + + // On battery state change, suspend / resume matching windows + if (previous_state != is_battery_powered) { + g_debug ("AC power = %d; State changed. Suspending/resuming windows.", + ! is_battery_powered); + + iterate_windows_kill_matching (); + } + return TRUE; +} diff --git a/src/events.h b/src/events.h new file mode 100644 index 0000000..60f173c --- /dev/null +++ b/src/events.h @@ -0,0 +1,20 @@ +#ifndef XSUSPENDER_EVENT_HANDLERS_H +#define XSUSPENDER_EVENT_HANDLERS_H + +#include "xsuspender.h" + + +#define SUSPEND_PENDING_INTERVAL 1 +#define PERIODIC_RESUME_INTERVAL 1 +#define CHECK_BATTERY_INTERVAL 10 + + +void xsus_init_event_handlers (); + +void on_active_window_changed (WnckScreen *screen, WnckWindow *prev_active_window); +int on_suspend_pending_windows (); +int on_periodic_window_wake_up (); +int on_check_battery_powered (); + + +#endif // XSUSPENDER_EVENT_HANDLERS_H diff --git a/src/exec.c b/src/exec.c new file mode 100644 index 0000000..d24c364 --- /dev/null +++ b/src/exec.c @@ -0,0 +1,98 @@ +#include "exec.h" + +#include +#include + +#include "macros.h" + + +static inline +int +execute (char **argv, + char **envp) +{ + g_autoptr (GError) err = NULL; + gint exit_status = -1; + GSpawnFlags flags = IS_DEBUG ? + G_SPAWN_DEFAULT : + G_SPAWN_STDOUT_TO_DEV_NULL | G_SPAWN_STDERR_TO_DEV_NULL; + + g_spawn_sync (NULL, argv, envp, flags | G_SPAWN_SEARCH_PATH, + NULL, NULL, NULL, NULL, &exit_status, &err); + if (err) + g_warning ("Unexpected subprocess execution error: %s", err->message); + + return exit_status; +} + + +int +xsus_exec_subprocess (char **argv, + WindowEntry *entry) +{ + if (! argv) + return 0; + + // Provide window data to subprocess via the environment + char *envp[] = { + g_strdup_printf ("PID=%d", entry->pid), + g_strdup_printf ("XID=%#lx", entry->xid), + g_strdup_printf ("WM_NAME=%s", entry->wm_name), + g_strdup_printf ("PATH=%s", g_getenv ("PATH")), + g_strdup_printf ("LC_ALL=C"), // Speeds up locale-aware shell utils + NULL + }; + + // Execute and return result + g_debug ("Exec %#lx (%d): %s", entry->xid, entry->pid, argv[2]); + int exit_status = execute (argv, envp); + g_debug ("Exit status: %d", exit_status); + + // Free envp + char **e = envp; while (*e) g_free(*e++); + + return exit_status; +} + + +int +xsus_kill_subtree (pid_t pid, + int signal, + char *cmd_pattern) +{ + if (! cmd_pattern) + return 0; + + g_assert (signal == SIGSTOP || signal == SIGCONT); + char *sig = signal == SIGSTOP ? "STOP" : "CONT"; + + // Prefer this short script to using pstree (induces extra dependency) + // or involved parsing of /proc + g_autofree char *script = g_strdup_printf ( + "pid=%d\n" + "ps_output=\"$(ps -e -o ppid,pid,cmd | awk \"/%s/\"'{ print $1, $2 }')\"\n" + "while [ \"$pid\" ]; do\n" + " pid=\"$(echo \"$ps_output\" | awk \"/^($pid) /{ print \\$2 }\")\"\n" + " echo -n $pid\" \"\n" + " pid=\"$(echo \"$pid\" | paste -sd '|' -)\"\n" + "done%s | xargs kill -%s 2>/dev/null", + pid, cmd_pattern, (IS_DEBUG ? " | tee /dev/fd/2" : ""), sig); + + char *argv[] = { + "sh", "-c", script, NULL}; + char *envp[] = { + g_strdup_printf ("PATH=%s", g_getenv ("PATH")), + g_strdup_printf ("LC_ALL=C"), // Speeds up locale-aware awk + NULL + }; + + // Execute and return result + g_debug ("Exec: pstree %d | kill -%s", pid, sig); + int exit_status = execute (argv, envp); + g_debug ("Exit status: %d", exit_status); + + // Free envp + char **e = envp; while (*e) g_free(*e++); + + return exit_status; +} diff --git a/src/exec.h b/src/exec.h new file mode 100644 index 0000000..1491d9e --- /dev/null +++ b/src/exec.h @@ -0,0 +1,12 @@ +#ifndef XSUSPENDER_SUBPROCESS_H +#define XSUSPENDER_SUBPROCESS_H + +#include + +#include "xsuspender.h" + + +int xsus_exec_subprocess (char **argv, WindowEntry *entry); +int xsus_kill_subtree (pid_t pid, int signal, char *cmd_pattern); + +#endif // XSUSPENDER_SUBPROCESS_H diff --git a/src/macros.h b/src/macros.h new file mode 100644 index 0000000..316ee68 --- /dev/null +++ b/src/macros.h @@ -0,0 +1,43 @@ +#ifndef XSUSPENDER_MACROS_H +#define XSUSPENDER_MACROS_H + +#include + +// Remove once glib-2.44+ is ubiquitous +#if !defined(g_auto) || \ + !defined(g_autoptr) || \ + !defined(g_autofree) + +static inline void +_cleanup_generic_autofree (void *p) +{ + void **pp = (void**) p; + if (*pp) + g_free (*pp); +} + +#define _GNUC_CLEANUP(func) __attribute__((cleanup(func))) +#define _AUTOPTR_FUNC_NAME(TypeName) _xsus_autoptr_cleanup_##TypeName +#define _AUTOPTR_TYPENAME(TypeName) TypeName##_autoptr +#define _AUTO_FUNC_NAME(TypeName) _xsus_auto_cleanup_##TypeName +#define _DEFINE_AUTOPTR_CLEANUP_FUNC(TypeName, func) \ + typedef TypeName *_AUTOPTR_TYPENAME(TypeName); \ + static inline void _AUTOPTR_FUNC_NAME(TypeName) (TypeName **_ptr) { if (*_ptr) (func) (*_ptr); } +#define _DEFINE_AUTO_CLEANUP_FREE_FUNC(TypeName, func) \ + static inline void _AUTO_FUNC_NAME(TypeName) (TypeName *_ptr) { if (*_ptr) (func) (*_ptr); } + +_DEFINE_AUTOPTR_CLEANUP_FUNC(GError, g_error_free) +_DEFINE_AUTOPTR_CLEANUP_FUNC(GKeyFile, g_key_file_unref) +_DEFINE_AUTOPTR_CLEANUP_FUNC(GOptionContext, g_option_context_free) +_DEFINE_AUTO_CLEANUP_FREE_FUNC(GStrv, g_strfreev) +#undef _DEFINE_AUTO_CLEANUP_FREE_FUNC +#undef _DEFINE_AUTOPTR_CLEANUP_FUNC + +#define g_autoptr(TypeName) _GNUC_CLEANUP(_AUTOPTR_FUNC_NAME(TypeName)) _AUTOPTR_TYPENAME(TypeName) +#define g_auto(TypeName) _GNUC_CLEANUP(_AUTO_FUNC_NAME(TypeName)) TypeName +#define g_autofree _GNUC_CLEANUP(_cleanup_generic_autofree) + +#endif + + +#endif // XSUSPENDER_MACROS_H diff --git a/src/main.c b/src/main.c deleted file mode 100644 index e69de29..0000000 diff --git a/src/rule.c b/src/rule.c new file mode 100644 index 0000000..2a5d1f3 --- /dev/null +++ b/src/rule.c @@ -0,0 +1,73 @@ +#include "rule.h" + +#include +#include + +#include "xsuspender.h" + +Rule* +xsus_rule_copy (Rule *orig) +{ + Rule *rule = g_memdup (orig, sizeof (Rule)); + + // Duplicate strings explicitly + rule->needle_wm_name = g_strdup (orig->needle_wm_name); + rule->needle_wm_class = g_strdup (orig->needle_wm_class); + rule->needle_wm_class_group = g_strdup (orig->needle_wm_class_group); + + rule->exec_suspend = g_strdupv (orig->exec_suspend); + rule->exec_resume = g_strdupv (orig->exec_resume); + + return rule; +} + + +void +xsus_rule_free (Rule *rule) +{ + g_free (rule->needle_wm_class); + g_free (rule->needle_wm_class_group); + g_free (rule->needle_wm_name); + + g_strfreev (rule->exec_suspend); + g_strfreev (rule->exec_resume); + + g_free (rule); +} + + +static inline +gboolean +str_contains (const char *haystack, + const char *needle) +{ + if (! needle) + return TRUE; + if (! haystack) + return FALSE; + + return g_strstr_len (haystack, -1, needle) != NULL; +} + + +Rule* +xsus_window_get_rule (WnckWindow *window) +{ + if (! WNCK_IS_WINDOW (window)) + return NULL; + + const char *wm_name = wnck_window_get_name (window), + *wm_class = wnck_window_get_class_instance_name (window), + *wm_class_group = wnck_window_get_class_group_name (window); + + for (int i = 0; rules[i]; ++i) { + Rule *rule = rules[i]; + // If all provided matching specifiers match + if (str_contains (wm_class_group, rule->needle_wm_class_group) && + str_contains (wm_class, rule->needle_wm_class) && + str_contains (wm_name, rule->needle_wm_name)) { + return rule; + } + } + return NULL; +} diff --git a/src/rule.h b/src/rule.h new file mode 100644 index 0000000..e51f05f --- /dev/null +++ b/src/rule.h @@ -0,0 +1,34 @@ +#ifndef XSUSPENDER_RULE_H +#define XSUSPENDER_RULE_H + +#include +#include + + +// Configuration rule +typedef struct Rule { + char* needle_wm_name; + char* needle_wm_class; + char* needle_wm_class_group; + + char** exec_suspend; + char** exec_resume; + + char* subtree_pattern; + + guint16 delay; + guint16 resume_every; + guint16 resume_for; + + gboolean send_signals; + gboolean only_on_battery; + gboolean auto_on_battery; +} Rule; + + +Rule* xsus_rule_copy (Rule *rule); +void xsus_rule_free (Rule *rule); +Rule* xsus_window_get_rule (WnckWindow *window); + + +#endif // XSUSPENDER_RULE_H diff --git a/src/xsuspender.c b/src/xsuspender.c new file mode 100644 index 0000000..247b1fa --- /dev/null +++ b/src/xsuspender.c @@ -0,0 +1,283 @@ +#include "xsuspender.h" + +#include +#include +#include +#include + +#include +#include + +#include "config.h" +#include "entry.h" +#include "events.h" +#include "exec.h" +#include "macros.h" +#include "rule.h" + + +static +GMainLoop *loop; + + +gboolean +xsus_signal_stop (WindowEntry *entry) +{ + Rule *rule = entry->rule; + + // Run the windows' designated exec_suspend script, if any. + // If the subprocess fails, exit early without stopping the process. + if (xsus_exec_subprocess (rule->exec_suspend, entry) != 0) { + g_debug ("Subprocess failed; not stopping the process."); + return FALSE; + } + + // Mark the process as suspended and kill it + g_debug ("kill -STOP %d", entry->pid); + suspended_entries = g_slist_prepend (suspended_entries, entry); + + if (rule->send_signals) { + kill (entry->pid, SIGSTOP); + xsus_kill_subtree (entry->pid, SIGSTOP, rule->subtree_pattern); + } + + return TRUE; +} + + +gboolean +xsus_signal_continue (WindowEntry *entry) +{ + Rule *rule = entry->rule; + + // Run the window's designated exec_resume script, if any. + // If the subprocess fails, continue the process anyway. Cause what were + // you going to do with a stuck process??? + if (xsus_exec_subprocess (rule->exec_resume, entry) != 0) + g_debug ("Subprocess failed; resuming the process anyway."); + + // Mark the process as not suspended and kill it + g_debug ("kill -CONT %d", entry->pid); + suspended_entries = g_slist_remove (suspended_entries, entry); + + if (rule->send_signals) { + // Resume subprocesses before parent process to avoid the parent + // workers manager considering them "stuck" (cf. Firefox) + xsus_kill_subtree (entry->pid, SIGCONT, rule->subtree_pattern); + + kill (entry->pid, SIGCONT); + } + + // Free the entry now + xsus_window_entry_free (entry); + + return TRUE; +} + + +void +xsus_window_entry_enqueue (WindowEntry *entry, + unsigned delay) { + // Mark the time of suspension + entry->suspend_timestamp = time (NULL) + delay; + + // Schedule suspension + queued_entries = g_slist_prepend (queued_entries, entry); +} + + +void +xsus_window_resume (WnckWindow *window) +{ + Rule *rule = xsus_window_get_rule (window); + + // No matching configuration rule, window was not suspended + if (! rule) + return; + + WindowEntry *entry; + + // Remove the process from the pending queue + if ((entry = xsus_entry_find_for_window_rule (window, rule, queued_entries))) { + g_debug ("Removing window %#lx (%d) from suspension queue: %s", + wnck_window_get_xid (window), + wnck_window_get_pid (window), + wnck_window_get_name (window)); + queued_entries = g_slist_remove (queued_entries, entry); + xsus_window_entry_free (entry); + return; + } + + // Continue the process if it was actually stopped + if ((entry = xsus_entry_find_for_window_rule (window, rule, suspended_entries))) { + g_debug ("Resuming window %#lx (%d): %s", + wnck_window_get_xid (window), + wnck_window_get_pid (window), + wnck_window_get_name (window)); + xsus_signal_continue (entry); + return; + } +} + + +void +xsus_window_suspend (WnckWindow *window) +{ + Rule *rule = xsus_window_get_rule (window); + + // No matching configuration rule, nothing to suspend + if (! rule) + return; + + // Rule only applies on battery power and we are not + if (! is_battery_powered && rule->only_on_battery) + return; + + // We shouldn't be having an entry for this window in the queues already ... +#ifndef NDEBUG + g_assert (! xsus_entry_find_for_window_rule (window, rule, suspended_entries)); + g_assert (! xsus_entry_find_for_window_rule (window, rule, queued_entries)); +#endif + + // Schedule window suspension + g_debug ("Suspending window in %ds: %#lx (%d): %s", + rule->delay, + wnck_window_get_xid (window), + wnck_window_get_pid (window), + wnck_window_get_name (window)); + WindowEntry *entry = xsus_window_entry_new (window, rule); + xsus_window_entry_enqueue (entry, rule->delay); +} + + +int +xsus_init () +{ + g_debug ("Initializing."); + IS_DEBUG = g_getenv ("G_MESSAGES_DEBUG") ? TRUE: FALSE; + + // Nowadays common to have a single screen which combines several physical + // monitors. So it's ok to take the default. See: + // https://developer.gnome.org/libwnck/stable/WnckScreen.html#WnckScreen.description + // https://developer.gnome.org/gdk4/stable/GdkScreen.html#GdkScreen.description + if (! wnck_screen_get_default ()) + g_critical ("Default screen is NULL. Not an X11 system? Too bad."); + + // Parse the configuration files + rules = parse_config (); + + is_battery_powered = FALSE; + + // Init entry lists + suspended_entries = NULL; + queued_entries = NULL; + + xsus_init_event_handlers (); + + // Install exit signal handlers to exit gracefully + signal (SIGINT, xsus_exit); + signal (SIGTERM, xsus_exit); + signal (SIGABRT, xsus_exit); + + // Don't call this function again, we're done + return FALSE; +} + + +void +xsus_exit () +{ + // Quit the main loop and thus, hopefully, exit + g_debug ("Exiting ..."); + g_main_loop_quit (loop); +} + + +static inline +void +cleanup () +{ + wnck_shutdown (); + + // Resume processes we have suspended; deallocate window entries + GSList *l = suspended_entries; + while (l) { + WindowEntry *entry = l->data; + l = l->next; + xsus_signal_continue (entry); + } + + for (GSList *e = queued_entries; e; e = e->next) + xsus_window_entry_free (e->data); + + g_slist_free (suspended_entries); + g_slist_free (queued_entries); + + // Delete rules + for (int i = 0; rules[i]; ++i) + xsus_rule_free (rules[i]); + g_free (rules); +} + + +static +void +parse_args (int *argc, + char **argv[]) +{ + g_autoptr (GOptionContext) context = g_option_context_new (NULL); + + g_option_context_set_help_enabled (context, FALSE); + + g_option_context_set_summary (context, + "Automatically suspend inactive (unfocused) X11 windows (processes)\n" + "to save battery life. (v" PROJECT_VERSION ")"); + g_option_context_set_description (context, + "The program looks for configuration in ~/.config/xsuspender.conf.\n" + "Example provided in " EXAMPLE_CONF ".\n" + "You can copy it over and adapt it to taste.\n" + "\n" + "To debug new configuration before it is put into use (recommended),\n" + "set environment variable G_MESSAGES_DEBUG=all, i.e.:\n" + "\n" + " G_MESSAGES_DEBUG=all xsuspender\n" + "\n" + "To daemonize the program, run it as:\n" + "\n" + " nohup xsuspender >/dev/null & disown\n" + "\n" + "or, better yet, ask your X session manager to run it for you.\n" + "\n" + "Read xsuspender(1) manual for more information."); + + if (! g_option_context_parse (context, argc, argv, NULL)) { + printf ("%s", g_option_context_get_help (context, TRUE, NULL)); + exit (EXIT_FAILURE); + } +} + + +int +main (int argc, + char *argv[]) +{ + // Parse command line arguments + gdk_init (&argc, &argv); + parse_args (&argc, &argv); + + // Make g_critical() always exit + g_log_set_always_fatal (G_LOG_LEVEL_CRITICAL); + + // Delay initialization until we're within the loop + g_timeout_add (1, xsus_init, NULL); + + // Enter the main loop + loop = g_main_loop_new (NULL, FALSE); + g_main_loop_run (loop); + + g_main_loop_unref (loop); + cleanup (); + g_debug ("Bye."); + + return EXIT_SUCCESS; +} diff --git a/src/xsuspender.h b/src/xsuspender.h new file mode 100644 index 0000000..28d8010 --- /dev/null +++ b/src/xsuspender.h @@ -0,0 +1,44 @@ +#ifndef XSUSPENDER_XSUSPENDER_H +#define XSUSPENDER_XSUSPENDER_H + +#include +#include + +#include "entry.h" +#include "rule.h" + + +#ifndef PROJECT_NAME +#warning "PROJECT_NAME undefined" +#define PROJECT_NAME "xsuspender" +#endif + +#ifndef PROJECT_VERSION +#warning "PROJECT_VERSION undefined" +#define PROJECT_VERSION "0" +#endif + +#ifndef EXAMPLE_CONF +#warning "EXAMPLE_CONF undefined" +#define EXAMPLE_CONF "/usr/share/doc/xsuspender/examples/xsuspender.conf" +#endif + + +gboolean IS_DEBUG; +gboolean is_battery_powered; + +GSList *suspended_entries; // List of suspended WindowEntry +GSList *queued_entries; + +Rule **rules; // Matching rules from config files + + +gboolean xsus_signal_stop (WindowEntry *entry); +gboolean xsus_signal_continue (WindowEntry *entry); +void xsus_window_entry_enqueue (WindowEntry *entry, unsigned delay); +void xsus_window_suspend (WnckWindow *window); +void xsus_window_resume (WnckWindow *window); +int xsus_init (); +void xsus_exit (); + +#endif // XSUSPENDER_XSUSPENDER_H diff --git a/xappsuspender.conf b/xappsuspender.conf deleted file mode 100644 index 90738c1..0000000 --- a/xappsuspender.conf +++ /dev/null @@ -1,4 +0,0 @@ -wm_class = chromium -wm_class = Firefox - -suspend_after = 10s