Skip to content

Commit 6d0ede9

Browse files
committed
WIP of #125: screen locking detection
separate backends implemented via a 'multi' meta-backend. Uses config like this: ``` backends: [multi] multi: locked: pushover: user_key: user-api-key unfocused: default: {} focused: {} ``` This config would cause no notifications if the shell is focused, desktop notifications when unfocused, and pushover notifications when the screen is locked. Freaking magic. 🎉
1 parent c7cbf8f commit 6d0ede9

File tree

4 files changed

+157
-0
lines changed

4 files changed

+157
-0
lines changed

ntfy/backends/multi.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from importlib import import_module
2+
try:
3+
from ..terminal import is_focused
4+
except ImportError:
5+
def is_focused():
6+
return True
7+
from ..screensaver import is_locked
8+
9+
10+
def notify(title,
11+
message,
12+
locked=None,
13+
focused=None,
14+
unfocused=None,
15+
retcode=None):
16+
for condition, options in ((is_locked, locked),
17+
(is_focused, focused),
18+
(lambda: not is_focused(), unfocused)):
19+
for backend_name, backend_options in options.items():
20+
if not condition():
21+
continue
22+
backend = import_module('ntfy.backends.{}'.format(
23+
backend_options.get('backend', backend_name)))
24+
backend_options.pop('backend', None)
25+
backend.notify(title, message, retcode=retcode, **backend_options)

ntfy/cli.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131

3232
def is_focused():
3333
return True
34+
from .screensaver import is_locked
3435

3536

3637
def run_cmd(args):
@@ -62,6 +63,8 @@ def run_cmd(args):
6263
retcode = process.returncode
6364
if args.longer_than is not None and duration <= args.longer_than:
6465
return None, None
66+
if args.locked_only and not is_locked():
67+
return None, None
6568
if args.unfocused_only and is_focused():
6669
return None, None
6770
message = _result_message(args.command if not args.hide_command else None,
@@ -228,6 +231,12 @@ def default_sender(args):
228231
type=int,
229232
metavar='N',
230233
help="Only notify if the command runs longer than N seconds")
234+
done_parser.add_argument(
235+
'--locked-only',
236+
action='store_true',
237+
default=False,
238+
dest='locked_only',
239+
help='Only notify if the screen is locked')
231240
done_parser.add_argument(
232241
'-b',
233242
'--background-only',
@@ -281,6 +290,12 @@ def default_sender(args):
281290
type=int,
282291
metavar='N',
283292
help="Only notify if the command runs longer than N seconds")
293+
shell_integration_parser.add_argument(
294+
'--locked-only',
295+
action='store_true',
296+
default=False,
297+
dest='locked_only',
298+
help='Only notify if the screen is locked')
284299
shell_integration_parser.add_argument(
285300
'-f',
286301
'--foreground-too',

ntfy/screensaver.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
from shlex import split
2+
from subprocess import check_output, check_call, CalledProcessError, PIPE
3+
4+
# some adapted from
5+
# https://github.com/mtorromeo/xdg-utils/blob/master/scripts/xdg-screensaver.in#L540
6+
7+
8+
def xscreensaver_detect():
9+
try:
10+
check_call(split('pgrep xscreensaver'), stdout=PIPE)
11+
except (CalledProcessError, OSError):
12+
return False
13+
else:
14+
return True
15+
16+
17+
def xscreensaver_is_locked():
18+
return 'screen locked' in check_output(split('xscreensaver-command -time'))
19+
20+
21+
def lightlocker_detect():
22+
try:
23+
check_call(split('pgrep light-locker'), stdout=PIPE)
24+
except (CalledProcessError, OSError):
25+
return False
26+
else:
27+
return True
28+
29+
30+
def lightlocker_is_active():
31+
return 'The screensaver is active' in check_output(split(
32+
'light-locker-command -q'))
33+
34+
35+
def gnomescreensaver_detect():
36+
try:
37+
import dbus
38+
except ImportError:
39+
return False
40+
bus = dbus.SessionBus()
41+
dbus_obj = bus.get_object('org.freedesktop.DBus',
42+
'/org/freedesktop/DBus')
43+
dbus_iface = dbus.Interface(dbus_obj,
44+
dbus_interface='org.freedesktop.DBus')
45+
try:
46+
dbus_iface.GetNameOwner('org.gnome.ScreenSaver')
47+
except dbus.DBusException as e:
48+
if e.get_dbus_name() == 'org.freedesktop.DBus.Error.NameHasNoOwner':
49+
return False
50+
else:
51+
raise e
52+
else:
53+
return True
54+
55+
56+
def gnomescreensaver_is_locked():
57+
import dbus
58+
bus = dbus.SessionBus()
59+
dbus_obj = bus.get_object('org.gnome.ScreenSaver',
60+
'/org/gnome/ScreenSaver')
61+
dbus_iface = dbus.Interface(dbus_obj,
62+
dbus_interface='org.gnome.ScreenSaver')
63+
return bool(dbus_iface.GetActive())
64+
65+
66+
def matescreensaver_detect():
67+
try:
68+
import dbus
69+
except ImportError:
70+
return False
71+
bus = dbus.SessionBus()
72+
dbus_obj = bus.get_object('org.freedesktop.DBus',
73+
'/org/freedesktop/DBus')
74+
dbus_iface = dbus.Interface(dbus_obj,
75+
dbus_interface='org.freedesktop.DBus')
76+
try:
77+
dbus_iface.GetNameOwner('org.mate.ScreenSaver')
78+
except dbus.DBusException as e:
79+
if e.get_dbus_name() == 'org.freedesktop.DBus.Error.NameHasNoOwner':
80+
return False
81+
else:
82+
raise e
83+
else:
84+
return True
85+
86+
87+
def matescreensaver_is_locked():
88+
import dbus
89+
bus = dbus.SessionBus()
90+
dbus_obj = bus.get_object('org.mate.ScreenSaver',
91+
'/org/mate/ScreenSaver')
92+
dbus_iface = dbus.Interface(dbus_obj,
93+
dbus_interface='org.mate.ScreenSaver')
94+
return bool(dbus_iface.GetActive())
95+
96+
97+
def is_locked():
98+
if xscreensaver_detect():
99+
return xscreensaver_is_locked()
100+
if lightlocker_detect():
101+
return lightlocker_is_active()
102+
if gnomescreensaver_detect():
103+
return gnomescreensaver_is_locked()
104+
if matescreensaver_detect():
105+
return matescreensaver_is_locked()
106+
return True

tests/test_cli.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ def test_default(self, mock_Popen):
2424
args.pid = None
2525
args.unfocused_only = False
2626
args.hide_command = False
27+
args.locked_only = False
2728
self.assertEqual(('"true" succeeded in 0:00 minutes', 0), run_cmd(args))
2829

2930
@patch('ntfy.cli.Popen')
@@ -36,6 +37,7 @@ def test_emoji(self, mock_Popen):
3637
args.no_emoji = False
3738
args.unfocused_only = False
3839
args.hide_command = False
40+
args.locked_only = False
3941
self.assertEqual((':white_check_mark: "true" succeeded in 0:00 minutes', 0),
4042
run_cmd(args))
4143

@@ -55,6 +57,7 @@ def test_longerthan(self, mock_Popen):
5557
args.pid = None
5658
args.unfocused_only = False
5759
args.hide_command = False
60+
args.locked_only = False
5861
self.assertEqual((None, None), run_cmd(args))
5962

6063
@patch('ntfy.cli.Popen')
@@ -66,6 +69,7 @@ def test_failure(self, mock_Popen):
6669
args.pid = None
6770
args.unfocused_only = False
6871
args.hide_command = False
72+
args.locked_only = False
6973
self.assertEqual(('"false" failed (code 42) in 0:00 minutes', 42), run_cmd(args))
7074

7175
@patch('ntfy.cli.Popen')
@@ -77,6 +81,7 @@ def test_stdout(self, mock_Popen):
7781
args.pid = None
7882
args.unfocused_only = False
7983
args.hide_command = False
84+
args.locked_only = False
8085
# not actually used
8186
args.stdout = True
8287
args.stderr = False
@@ -91,6 +96,7 @@ def test_stderr(self, mock_Popen):
9196
args.pid = None
9297
args.unfocused_only = False
9398
args.hide_command = False
99+
args.locked_only = False
94100
# not actually used
95101
args.stdout = False
96102
args.stderr = True
@@ -105,6 +111,7 @@ def test_stdout_and_stderr(self, mock_Popen):
105111
args.pid = None
106112
args.unfocused_only = False
107113
args.hide_command = False
114+
args.locked_only = False
108115
# not actually used
109116
args.stdout = True
110117
args.stderr = True
@@ -119,6 +126,7 @@ def test_failure_stdout_and_stderr(self, mock_Popen):
119126
args.pid = None
120127
args.unfocused_only = False
121128
args.hide_command = False
129+
args.locked_only = False
122130
# not actually used
123131
args.stdout = True
124132
args.stderr = True
@@ -143,6 +151,7 @@ def test_formatter(self):
143151
args.longer_than = -1
144152
args.unfocused_only = False
145153
args.hide_command = False
154+
args.locked_only = False
146155
self.assertEqual(('"true" succeeded in 1:05 minutes', 0), run_cmd(args))
147156

148157
def test_formatter_failure(self):
@@ -153,6 +162,7 @@ def test_formatter_failure(self):
153162
args.longer_than = -1
154163
args.unfocused_only = False
155164
args.hide_command = False
165+
args.locked_only = False
156166
self.assertEqual(('"false" failed (code 1) in 0:10 minutes', 1), run_cmd(args))
157167

158168

@@ -186,6 +196,7 @@ def test_watch_pid(self, mock_process):
186196
args = MagicMock()
187197
args.pid = 1
188198
args.unfocused_only = False
199+
args.locked_only = False
189200
self.assertEqual('PID[1]: "cmd" finished in 0:00 minutes',
190201
run_cmd(args)[0])
191202

0 commit comments

Comments
 (0)