Skip to content

Commit c954020

Browse files
committed
gtk_application_inhibit based wakepy Method
1 parent 6b5d094 commit c954020

File tree

10 files changed

+711
-1
lines changed

10 files changed

+711
-1
lines changed

docs/source/changelog.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@
44
🗓️ Unreleased
55

66
### ✨ Features
7+
- New method: "gtk_application_inhibit", which calls the [gtk_application_inhibit()](https://docs.gtk.org/gtk4/method.Application.inhibit.html) through the GObject Instrospection python package (import name `gi`). This supports both GTK3 and GTK4 based Desktop Environments (examples: GNOME, Xfce, Cinnamon, LXDE, MATE, Unity, Budgie and Pantheon). This can use _system python_, so installing PyGObject is not needed. ([#407](https://github.com/fohrloop/wakepy/pull/407))
78
- Update the wakepy CLI printout: Adds the used Method and activated Mode to the printout ([#434](https://github.com/fohrloop/wakepy/pull/434))
89
- Added `-v` (INFO) and `-vv` (DEBUG) verbosity flags for the [wakepy CLI command](https://wakepy.readthedocs.io/stable/cli-api.html). ([#439](https://github.com/fohrloop/wakepy/pull/439))
910
- Add [Mode.method](https://wakepy.readthedocs.io/stable/api-reference.html#wakepy.Mode.method), [ActivationResult.method](https://wakepy.readthedocs.io/stable/api-reference.html#wakepy.ActivationResult.method) and [MethodActivationResult.method](https://wakepy.readthedocs.io/stable/api-reference.html#wakepy.MethodActivationResult.method) attributes, which are instances of [MethodInfo](https://wakepy.readthedocs.io/stable/api-reference.html#wakepy.MethodInfo) ([#459](https://github.com/fohrloop/wakepy/pull/459), [#460](https://github.com/fohrloop/wakepy/pull/460), [#464](https://github.com/fohrloop/wakepy/pull/464))
1011
- Make [Mode.active_method](https://wakepy.readthedocs.io/stable/api-reference.html#wakepy.Mode.active_method) a [MethodInfo](https://wakepy.readthedocs.io/stable/api-reference.html#wakepy.MethodInfo) instance (was a string) ([#459](https://github.com/fohrloop/wakepy/pull/459))
1112

12-
### Minor Enhancements
13+
### ✨ Enhancements
1314
- Better error messages: When selected Method is not part of the selected Mode ([#427](https://github.com/fohrloop/wakepy/pull/427)) and when a D-Bus -based method fails ([#438](https://github.com/fohrloop/wakepy/pull/438))
1415
- Enhance Mode Activation Observability. Add logging (DEBUG and INFO level) for different parts in the Mode activation process. Show which methods are to be tried, and log any success and failure of activating a Mode. Improved the `ActivationResult.get_failure_text()` output. Added `NoMethodsWarning` which is issued if trying to activate a Mode with an empty list of methods. ([#411](https://github.com/fohrloop/wakepy/pull/411))
1516
- Make ActivationWarning use proper stacklevel, so that issued warnings point to user code, and not into wakepy source code. ([#432](https://github.com/fohrloop/wakepy/pull/432))
17+
- Better error message when selected Method is not part of the selected Mode ([#427](https://github.com/fohrloop/wakepy/pull/427))
1618

1719
### 🐞 Bug fixes
1820
- Fix prioritized order of Methods ([#429](https://github.com/fohrloop/wakepy/pull/429)).

src/wakepy/methods/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,6 @@
2929
from . import _testing as _testing
3030
from . import freedesktop as freedesktop
3131
from . import gnome as gnome
32+
from . import gtk as gtk
3233
from . import macos as macos
3334
from . import windows as windows

src/wakepy/methods/gtk/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from .gtk_application_inhibit import (
2+
GtkApplicationInhibitNoIdle as GtkApplicationInhibitNoIdle,
3+
)
4+
from .gtk_application_inhibit import (
5+
GtkApplicationInhibitNoSuspend as GtkApplicationInhibitNoSuspend,
6+
)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from __future__ import annotations
2+
3+
import enum
4+
import typing
5+
from abc import ABC, abstractmethod
6+
7+
from wakepy.core import Method, ModeName, PlatformType
8+
from wakepy.pyinhibitor import get_inhibitor
9+
10+
if typing.TYPE_CHECKING:
11+
from typing import Optional
12+
13+
14+
class GtkIhibitFlag(enum.IntFlag):
15+
"""The ApplicationInhibitFlags from
16+
https://docs.gtk.org/gtk4/flags.ApplicationInhibitFlags.html
17+
"""
18+
19+
# Inhibit suspending the session or computer
20+
INHIBIT_SUSPEND = 4
21+
# Inhibit the session being marked as idle (and possibly locked).
22+
INHIBIT_IDLE = 8
23+
24+
25+
class _GtkApplicationInhibit(Method, ABC):
26+
"""Method using the gtk_application_inhibit().
27+
28+
https://docs.gtk.org/gtk4/method.Application.inhibit.html
29+
30+
Works on GTK 3 and 4.
31+
"""
32+
33+
supported_platforms = (PlatformType.UNIX_LIKE_FOSS,)
34+
inhibitor_module = "wakepy.methods.gtk.inhibitor"
35+
36+
@property
37+
@abstractmethod
38+
def flags(self) -> GtkIhibitFlag: ...
39+
40+
def __init__(self, **kwargs: object) -> None:
41+
super().__init__(**kwargs)
42+
self.inhibit_cookie: Optional[int] = None
43+
44+
def enter_mode(self) -> None:
45+
self.inhibitor = get_inhibitor(self.inhibitor_module)
46+
# TODO: use flags
47+
self.inhibitor.start()
48+
49+
def exit_mode(self) -> None:
50+
self.inhibitor.stop()
51+
52+
53+
class GtkApplicationInhibitNoSuspend(_GtkApplicationInhibit):
54+
name = "gtk_application_inhibit"
55+
mode_name = ModeName.KEEP_RUNNING
56+
flags = GtkIhibitFlag.INHIBIT_SUSPEND
57+
58+
59+
class GtkApplicationInhibitNoIdle(_GtkApplicationInhibit):
60+
name = "gtk_application_inhibit"
61+
mode_name = ModeName.KEEP_PRESENTING
62+
flags = GtkIhibitFlag.INHIBIT_IDLE | GtkIhibitFlag.INHIBIT_SUSPEND
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Inhibitor module which uses gtk_application_inhibit(), and follows the
2+
inhibitor module specification (can be used with the pyinhibitor server).
3+
4+
NOTE: Due to the inhibitor counter, this module should not be executed (via a
5+
forced re-import) more than once."""
6+
7+
from __future__ import annotations
8+
9+
import logging
10+
import threading
11+
import warnings
12+
13+
with warnings.catch_warnings():
14+
# Ignore the PyGIWarning: Gtk was imported without specifying a version
15+
# first. This should work on GtK 3 and 4.
16+
warnings.filterwarnings(action="ignore")
17+
from gi.repository import Gio, Gtk
18+
19+
logger = logging.getLogger(__name__)
20+
latest_inhibitor_identifier = 0
21+
22+
lock = threading.Lock()
23+
24+
25+
class Inhibitor:
26+
"""Inhibitor which uses GTK, namely the gtk_application_inhibit()
27+
28+
Docs: https://docs.gtk.org/gtk3/method.Application.inhibit.html
29+
"""
30+
31+
def __init__(self):
32+
self.app: Gtk.Application | None = None
33+
self.cookie: int | None = None
34+
35+
def start(self, *_) -> None:
36+
self.app = self._get_app()
37+
try:
38+
39+
# Docs: https://lazka.github.io/pgi-docs/#Gtk-4.0/classes/Application.html#Gtk.Application.inhibit
40+
cookie = self.app.inhibit(
41+
Gtk.ApplicationWindow(application=self.app),
42+
Gtk.ApplicationInhibitFlags(8), # prevent idle
43+
"wakelock requested (wakepy)",
44+
)
45+
if not cookie:
46+
raise RuntimeError(
47+
"Failed to inhibit the system (Gtk.Application.inhibit did not "
48+
"return a non-zero cookie)"
49+
)
50+
51+
self.cookie = cookie
52+
53+
# The hold() keeps the app alive even without a window.
54+
# Basically increments the internal hold count of the application.
55+
# Docs: https://lazka.github.io/pgi-docs/Gio-2.0/classes/Application.html#Gio.Application.hold
56+
self.app.hold()
57+
58+
except Exception as error:
59+
self.app.quit()
60+
raise RuntimeError(f"Failed to inhibit the system: {error}")
61+
62+
def _get_app(self) -> Gtk.Application:
63+
lock.acquire()
64+
# NOTE: Cannot register two apps with same applidation_id within the
65+
# same python process (not even on separate threads)! In addition,
66+
# quitting the app does not seem to unregister / make it possible to
67+
# reuse the application_id. Therefore, using unique application_id for
68+
# each instance.
69+
global latest_inhibitor_identifier
70+
latest_inhibitor_identifier += 1
71+
application_id = f"io.readthedocs.wakepy.inhibitor{latest_inhibitor_identifier}"
72+
try:
73+
app = Gtk.Application(
74+
application_id=application_id,
75+
flags=Gio.ApplicationFlags.IS_SERVICE | Gio.ApplicationFlags.NON_UNIQUE,
76+
)
77+
78+
# Cannot use the inhibit() if the app is not registered first.
79+
80+
logger.debug("Registering Gtk.Application with id %s", application_id)
81+
app.register()
82+
logger.debug("Registered Gtk.Application with id %s", application_id)
83+
except Exception as error:
84+
raise RuntimeError(
85+
f"Failed to create or register the Gtk.Application: {error}"
86+
) from error
87+
finally:
88+
lock.release()
89+
90+
return app
91+
92+
def stop(self) -> None:
93+
if self.cookie:
94+
self.app.uninhibit(self.cookie)
95+
self.cookie = None
96+
97+
# The app.release is the counterpart to app.hold(); decrement the internal
98+
# hold count of the application.
99+
self.app.release()
100+
self.app.quit()
101+
self.app = None

src/wakepy/pyinhibitor/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""This subpackage defines the python based inhibitor client and server.
2+
3+
With this package, it is possible to use python packages outside of your
4+
current python environment (for example, directly from your system python
5+
site-packages). The idea is to run a python server with the required packages
6+
and communicate with it via a unix socket.
7+
8+
The server can only utilize "inhibitor modules", which are simple python
9+
modules that define a class called Inhibitor. See the inhibitor_server.py
10+
for the full specification.
11+
12+
This works only on unix-like systems (on systems which support unix sockets).
13+
"""
14+
15+
from .inhibitors import get_inhibitor as get_inhibitor
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
from __future__ import annotations
2+
3+
import importlib.util
4+
import sys
5+
import typing
6+
import warnings
7+
from pathlib import Path
8+
from socket import AF_UNIX, SOCK_STREAM, socket
9+
10+
if sys.version_info < (3, 8): # pragma: no-cover-if-py-gte-38
11+
from typing_extensions import Protocol
12+
else: # pragma: no-cover-if-py-lt-38
13+
from typing import Protocol
14+
15+
if typing.TYPE_CHECKING:
16+
from typing import Type
17+
18+
19+
class Inhibitor(Protocol):
20+
"""The Inhibitor protocol. An inhibitor module should provide a class
21+
called Inhibitor which implements this protocol."""
22+
23+
def start(self, *args) -> None: ...
24+
def stop(self) -> None: ...
25+
26+
27+
CLIENT_CONNECTION_TIMEOUT = 60
28+
"""Time to wait (seconds) for the client to connect to the server."""
29+
CLIENT_MESSAGE_TIMEOUT = 1
30+
"""Time to wait (seconds) for each message from the client."""
31+
32+
33+
class InhibitorServer:
34+
"""A very simple class for inhibiting suspend/idle.
35+
36+
Communicates with a main process using a Unix domain socket.
37+
38+
What happens when run() is called:
39+
1. When the process starts, inhibit() is called. If it succeeds, this
40+
process sends "INHIBIT_OK". If it fails, this process sends
41+
"INHIBIT_ERROR:{errortext}" and exits.
42+
2. This process waits indefinitely for a "QUIT" message.
43+
3. When "QUIT" (or empty string) is received, uninhibit() is called. If it
44+
succeeds, this process sends "UNINHIBIT_OK". If it fails, this process
45+
sends "UNINHIBIT_ERROR". Then, this process exits.
46+
"""
47+
48+
def __init__(self):
49+
self._inhibitor: Inhibitor | None = None
50+
51+
def run(self, socket_path: str, inhibitor_module: str, *inhibit_args) -> None:
52+
"""Inhibit the system using inhibitor_module and wait for a quit
53+
message at socket_path.
54+
55+
Parameters
56+
----------
57+
socket_path : str
58+
The path to the Unix domain socket which is used for communication.
59+
inhibitor_module : str
60+
The python module that contains the Inhibitor class
61+
inhibit_args:
62+
Any arguments to the Inhibitor.start() method.
63+
"""
64+
server_socket = socket(AF_UNIX, SOCK_STREAM)
65+
Path(socket_path).expanduser().unlink(missing_ok=True)
66+
server_socket.bind(socket_path)
67+
68+
try:
69+
self._run(server_socket, inhibitor_module, *inhibit_args)
70+
finally:
71+
server_socket.close()
72+
73+
def _run(self, server_socket: socket, inhibitor_module: str, *inhibit_args) -> None:
74+
server_socket.listen(1) # Only allow 1 connection at a time
75+
client_socket = self._get_client_socket(server_socket)
76+
client_socket.settimeout(CLIENT_MESSAGE_TIMEOUT)
77+
78+
try:
79+
self.inhibit(inhibitor_module, *inhibit_args)
80+
self.send_message(client_socket, "INHIBIT_OK")
81+
except Exception as error:
82+
self.send_message(client_socket, f"INHIBIT_ERROR:{error}")
83+
sys.exit(0)
84+
85+
while True:
86+
# Called every `CLIENT_MESSAGE_TIMEOUT` seconds.
87+
should_quit = self.check_for_quit_message(client_socket)
88+
if should_quit:
89+
break
90+
91+
try:
92+
self.uninhibit()
93+
self.send_message(client_socket, "UNINHIBIT_OK")
94+
except Exception as error:
95+
self.send_message(client_socket, f"UNINHIBIT_ERROR:{error}")
96+
sys.exit(0)
97+
98+
@staticmethod
99+
def _get_client_socket(server_socket: socket) -> socket:
100+
server_socket.settimeout(CLIENT_CONNECTION_TIMEOUT)
101+
102+
try:
103+
client_socket, _ = server_socket.accept()
104+
except TimeoutError as e:
105+
raise TimeoutError(
106+
f"Client did not connect within {CLIENT_CONNECTION_TIMEOUT} seconds."
107+
) from e
108+
except KeyboardInterrupt:
109+
print("Interrupted manually. Exiting.")
110+
sys.exit(0)
111+
112+
return client_socket
113+
114+
def inhibit(self, inhibitor_module: str, *inhibit_args) -> None:
115+
"""Inhibit using the Inhibitor class in the given `inhibitor_module`.
116+
In case the operation fails, raises a RuntimeError."""
117+
inhibitor_class = self.get_inhibitor_class(inhibitor_module)
118+
self._inhibitor = inhibitor_class()
119+
self._inhibitor.start(*inhibit_args)
120+
121+
@staticmethod
122+
def get_inhibitor_class(inhibitor_module_path: str) -> Type[Inhibitor]:
123+
try:
124+
module_name = "__wakepy_inhibitor"
125+
spec = importlib.util.spec_from_file_location(
126+
module_name, inhibitor_module_path
127+
)
128+
module = importlib.util.module_from_spec(spec)
129+
sys.modules[module_name] = module
130+
spec.loader.exec_module(module)
131+
except ImportError as e:
132+
raise ImportError(
133+
f"{e} | Used python interpreter: {sys.executable}."
134+
) from e
135+
return module.Inhibitor
136+
137+
def uninhibit(self) -> None:
138+
"""Uninhibit what was inhibited. In case the operation fails, raises a
139+
RuntimeError."""
140+
if self._inhibitor:
141+
self._inhibitor.stop()
142+
self._inhibitor = None
143+
else:
144+
warnings.warn("Called uninhibit before inhibit -> doing nothing.")
145+
146+
def send_message(self, client_socket: socket, message: str) -> None:
147+
client_socket.sendall(message.encode())
148+
149+
def check_for_quit_message(self, sock: socket) -> bool:
150+
# waits until the socket gets a message
151+
try:
152+
request = sock.recv(1024).decode()
153+
except TimeoutError:
154+
return False
155+
print(f"Received request: {request}")
156+
# if the client disconnects, empty string is returned. This will make
157+
# sure that the server process quits automatically when it's not needed
158+
# anymore.
159+
return request == "QUIT" or request == ""
160+
161+
162+
if __name__ == "__main__":
163+
# This is the entry point for the inhibitor server, and it's called
164+
# automatically when using the start_inhibit_server()
165+
166+
if len(sys.argv) < 3:
167+
print(
168+
f"Usage: python {__file__} <socket_path> <inhibitor_module> "
169+
"[inhibit_args...]"
170+
)
171+
sys.exit(1)
172+
173+
# Get the socket path from the command-line arguments
174+
InhibitorServer().run(
175+
socket_path=sys.argv[1], inhibitor_module=sys.argv[2], *sys.argv[3:]
176+
)

0 commit comments

Comments
 (0)