Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

smartpause: use X API instead of xprintidle subprocess #358

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion debian/control
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Homepage: https://github.com/slgobinath/SafeEyes/

Package: safeeyes
Architecture: all
Depends: ${misc:Depends}, ${python3:Depends}, gir1.2-appindicator3-0.1, python3 (>= 3.4.0), python3-xlib, python3-dbus, gir1.2-notify-0.7, python3-babel, x11-utils, xprintidle, alsa-utils, python3-psutil
Depends: ${misc:Depends}, ${python3:Depends}, gir1.2-appindicator3-0.1, python3 (>= 3.4.0), python3-xlib, python3-dbus, gir1.2-notify-0.7, python3-babel, x11-utils, python3-xcffib, alsa-utils, python3-psutil
Description: Safe Eyes
Safe Eyes is a simple tool to remind you to take periodic breaks for your eyes. This is essential for anyone spending more time on the computer to avoid eye strain and other physical problems.
.
Expand Down
11 changes: 4 additions & 7 deletions safeeyes/plugins/smartpause/dependency_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,9 @@


def validate(plugin_config):
command = None
if Utility.DESKTOP_ENVIRONMENT == "gnome" and Utility.IS_WAYLAND:
command = "dbus-send"
else:
command = "xprintidle"
if not Utility.command_exist(command):
return _("Please install the command-line tool '%s'") % command
else:
return None
if not Utility.command_exist(command):
return _("Please install the command-line tool '%s'") % command

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we try to make the xcffib dependency optional, by putting an import check here?
Or keep it simple and not have maybe-dependencies for bundled components?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can Smart Pause plugin work without the xcffib library? If it cannot please add a check here. This check will only disable the plugin (not Safe Eyes) if the dependency is missing.

return None
25 changes: 25 additions & 0 deletions safeeyes/plugins/smartpause/interface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from abc import ABC, abstractmethod
from numbers import Integral


class IdleTimeInterface(ABC):
"""Tells how long the user has been idle."""
# This could be a typing.Protocol in Python 3.8.

@classmethod
@abstractmethod
def is_applicable(cls, context) -> bool:
"""Is this implementation usable in this context?"""
pass

@abstractmethod
def idle_seconds(self) -> Integral:
"""Time since last user input, in seconds.

Returns 0 on failure."""
pass

@abstractmethod
def destroy(self) -> None:
"""Destroy this checker (clean up any resources)."""
pass
247 changes: 152 additions & 95 deletions safeeyes/plugins/smartpause/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,90 +15,139 @@

# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
Safe Eyes smart pause plugin
"""

import datetime
import logging
import subprocess
import threading
import re
import os
from typing import Optional, Callable

import xcffib
import xcffib.xproto
import xcffib.screensaver

from safeeyes import Utility
from safeeyes.model import State
from .interface import IdleTimeInterface

"""
Safe Eyes smart pause plugin
"""
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import GLib

context = None
idle_condition = threading.Condition()
lock = threading.Lock()

context: Optional[dict] = None
active = False
idle_time = 0
enable_safe_eyes = None
disable_safe_eyes = None
enable_safe_eyes: Optional[Callable[[Optional[int]], None]] = None
disable_safe_eyes: Optional[Callable[[Optional[str]], None]] = None
postpone: Optional[Callable[[Optional[int]], None]] = None
smart_pause_activated = False
idle_start_time = None
next_break_time = None
idle_start_time: Optional[datetime.datetime] = None
next_break_time: Optional[datetime.datetime] = None
next_break_duration = 0
break_interval = 0
waiting_time = 2
interpret_idle_as_break = False
is_wayland_and_gnome = False
postpone_if_active = False
idle_checker: Optional[IdleTimeInterface] = None
_timer_event_id: Optional[int] = None


def __gnome_wayland_idle_time():
class GnomeWaylandIdleTime(IdleTimeInterface):
"""
Determine system idle time in seconds, specifically for gnome with wayland.
If there's a failure, return 0.
https://unix.stackexchange.com/a/492328/222290
"""
try:
output = subprocess.check_output([
'dbus-send',
'--print-reply',
'--dest=org.gnome.Mutter.IdleMonitor',
'/org/gnome/Mutter/IdleMonitor/Core',
'org.gnome.Mutter.IdleMonitor.GetIdletime'
])
return int(re.search(rb'\d+$', output).group(0)) / 1000
except BaseException as e:
logging.warning("Failed to get system idle time for gnome/wayland.")
logging.warning(str(e))
return 0

@classmethod
def is_applicable(cls, ctx) -> bool:
if ctx['desktop'] == 'gnome' and ctx['is_wayland']:
# ? Might work in all Gnome environments running Mutter whether they're Wayland or X?
return Utility.command_exist("dbus-send")

return False

def idle_seconds(self):
# noinspection PyBroadException
try:
output = subprocess.check_output([
'dbus-send',
'--print-reply',
'--dest=org.gnome.Mutter.IdleMonitor',
'/org/gnome/Mutter/IdleMonitor/Core',
'org.gnome.Mutter.IdleMonitor.GetIdletime'
])
return int(re.search(rb'\d+$', output).group(0)) / 1000
except Exception:
logging.warning("Failed to get system idle time for gnome/wayland.", exc_info=True)
return 0

def destroy(self) -> None:
pass


class X11IdleTime(IdleTimeInterface):

def __init__(self):
self.connection = xcffib.connect()
self.screensaver_ext = self.connection(xcffib.screensaver.key)

@classmethod
def is_applicable(cls, _context) -> bool:
return True

def idle_seconds(self):
# noinspection PyBroadException
try:
root_window = self.connection.get_setup().roots[0].root
query = self.screensaver_ext.QueryInfo(root_window)
info = query.reply()
# Convert to seconds
return info.ms_since_user_input / 1000
except Exception:
logging.warning("Failed to get system idle time from XScreenSaver API", exc_info=True)
return 0

def destroy(self) -> None:
self.connection.disconnect()


def __system_idle_time():
_idle_checkers = [
GnomeWaylandIdleTime,
X11IdleTime
]


def idle_checker_for_platform(ctx) -> Optional[IdleTimeInterface]:
"""
Get system idle time in minutes.
Return the idle time if xprintidle is available, otherwise return 0.
Create the appropriate idle checker for this context.
"""
try:
if is_wayland_and_gnome:
return __gnome_wayland_idle_time()
# Convert to seconds
return int(subprocess.check_output(['xprintidle']).decode('utf-8')) / 1000
except BaseException:
return 0
for cls in _idle_checkers:
if cls.is_applicable(ctx):
checker = cls()
logging.debug("Using idle checker %s", checker)
return checker

logging.warning("Could not find any appropriate idle checker.")
return None


def __is_active():
"""
Thread safe function to see if this plugin is active or not.
Function to see if this plugin is active or not.
"""
is_active = False
with lock:
is_active = active
return is_active
return active


def __set_active(is_active):
"""
Thread safe function to change the state of the plugin.
Function to change the state of the plugin.
"""
global active
with lock:
active = is_active
active = is_active


def init(ctx, safeeyes_config, plugin_config):
Expand All @@ -114,7 +163,6 @@ def init(ctx, safeeyes_config, plugin_config):
global waiting_time
global interpret_idle_as_break
global postpone_if_active
global is_wayland_and_gnome
logging.debug('Initialize Smart Pause plugin')
context = ctx
enable_safe_eyes = context['api']['enable_safeeyes']
Expand All @@ -126,93 +174,102 @@ def init(ctx, safeeyes_config, plugin_config):
break_interval = safeeyes_config.get(
'short_break_interval') * 60 # Convert to seconds
waiting_time = min(2, idle_time) # If idle time is 1 sec, wait only 1 sec
is_wayland_and_gnome = context['desktop'] == 'gnome' and context['is_wayland']


def __start_idle_monitor():
def __idle_monitor():
"""
Continuously check the system idle time and pause/resume Safe Eyes based on it.
Check the system idle time and pause/resume Safe Eyes based on it.
"""
global smart_pause_activated
global idle_start_time
while __is_active():
# Wait for waiting_time seconds
idle_condition.acquire()
idle_condition.wait(waiting_time)
idle_condition.release()

if __is_active():
# Get the system idle time
system_idle_time = __system_idle_time()
if system_idle_time >= idle_time and context['state'] == State.WAITING:
smart_pause_activated = True
idle_start_time = datetime.datetime.now()
logging.info('Pause Safe Eyes due to system idle')
disable_safe_eyes(None)
elif system_idle_time < idle_time and context['state'] == State.STOPPED:
logging.info('Resume Safe Eyes due to user activity')
smart_pause_activated = False
idle_period = (datetime.datetime.now() - idle_start_time)
idle_seconds = idle_period.total_seconds()
context['idle_period'] = idle_seconds
if interpret_idle_as_break and idle_seconds >= next_break_duration:
# User is idle for break duration and wants to consider it as a break
enable_safe_eyes()
elif idle_seconds < break_interval:
# Credit back the idle time
next_break = next_break_time + idle_period
enable_safe_eyes(next_break.timestamp())
else:
# User is idle for more than the time between two breaks
enable_safe_eyes()

if not __is_active():
return False # stop the timeout handler.

system_idle_time = idle_checker.idle_seconds()
if system_idle_time >= idle_time and context['state'] == State.WAITING:
smart_pause_activated = True
idle_start_time = datetime.datetime.now()
logging.info('Pause Safe Eyes due to system idle')
disable_safe_eyes(None)
elif system_idle_time < idle_time and context['state'] == State.STOPPED:
logging.info('Resume Safe Eyes due to user activity')
smart_pause_activated = False
idle_period = (datetime.datetime.now() - idle_start_time)
idle_seconds = idle_period.total_seconds()
context['idle_period'] = idle_seconds
if interpret_idle_as_break and idle_seconds >= next_break_duration:
# User is idle for break duration and wants to consider it as a break
enable_safe_eyes()
elif idle_seconds < break_interval:
# Credit back the idle time
next_break = next_break_time + idle_period
enable_safe_eyes(next_break.timestamp())
else:
# User is idle for more than the time between two breaks
enable_safe_eyes()

return True # keep this timeout handler registered.


def on_start():
"""
Start a thread to continuously call xprintidle.
Begin polling to check user idle time.
"""
global active
if not __is_active():
global idle_checker
global _timer_event_id

if __is_active():
# If SmartPause is already started, do not start it again
logging.debug('Start Smart Pause plugin')
__set_active(True)
Utility.start_thread(__start_idle_monitor)
return

logging.debug('Start Smart Pause plugin')
idle_checker = idle_checker_for_platform(context)

__set_active(True)

# FIXME: need to make sure that this gets updated if the waiting_time config changes
_timer_event_id = GLib.timeout_add_seconds(waiting_time, __idle_monitor)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From reading init(), it looks like waiting_time is almost always 2, except in the cases where plugin_config.idle_time is less than two seconds.

Is there still a use case for that requirement? I'm having trouble imagining what that might be for.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Users have the option to specify the minimum idle time to pause Safe Eyes (by default 5 sec). Executing xprintidle every 5 seconds may pause after seconds. That's why I made it less than seconds which is hard coded to 2. However if the user reduces the minimum idle time to 1 sec, waiting_time also will be 1 sec.

Waiting time is used to add a delay to the while loop so that it doesn't eat the CPU. If your solution does not need busy waiting, you can skip this.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does still need a waiting_time. The part I am unclear on is this

However if the user reduces the minimum idle time to 1 sec, waiting_time also will be 1 sec.

What sort of user is it that wants a minimum idle time of only one second? What kind of workflow do they have? How would they be troubled if their waiting_time never went below two seconds?

It must be very different than how I have considered using this, because I've set my minimum idle_time much higher than that.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not have any specific user requirements. Personally I set it to 5 seconds but in I didn't want to deal with issues complaining people about smart pause not respecting/not allowing a minimal waiting time. That's why I try my best to keep all settings configurable and let people to choose.

However your argument makes sense and we can set the minimum bound to be 5. Just checked the config.json it is already set to a minimum of 5. So we can go with a minimum of 5 seconds idle time and a waiting time slightly less than that.



def on_stop():
"""
Stop the thread from continuously calling xprintidle.
Stop polling to check user idle time.
"""
global active
global smart_pause_activated
global _timer_event_id
global idle_checker

if smart_pause_activated:
# Safe Eyes is stopped due to system idle
smart_pause_activated = False
return
logging.debug('Stop Smart Pause plugin')
__set_active(False)
idle_condition.acquire()
idle_condition.notify_all()
idle_condition.release()
GLib.source_remove(_timer_event_id)
_timer_event_id = None
if idle_checker is not None:
idle_checker.destroy()
idle_checker = None


def update_next_break(break_obj, dateTime):
def update_next_break(break_obj, break_time):
"""
Update the next break time.
"""
global next_break_time
global next_break_duration
next_break_time = dateTime
next_break_time = break_time
next_break_duration = break_obj.duration


def on_start_break(break_obj):
def on_start_break(_break_obj):
"""
Lifecycle method executes just before the break.
"""
if postpone_if_active:
# Postpone this break if the user is active
system_idle_time = __system_idle_time()
system_idle_time = idle_checker.idle_seconds()
if system_idle_time < 2:
postpone(2) # Postpone for 2 seconds

Expand Down
Loading