From 2c7721392c9d7e41b3b9eeea1beab72e36c70b23 Mon Sep 17 00:00:00 2001 From: Eric Gach Date: Tue, 18 Apr 2017 03:18:31 -0500 Subject: [PATCH] Inital rewrite of daemon I took the time here to split everything out on its own. I feel like this really makes the daemon easier to maintain. I'm currently testing it along with the lockscreen changes to see how it operates. --- .gitignore | 4 +- .../deskchanger/__init__.py | 0 .../deskchanger/application.py | 316 ++++++++++++++++++ .../deskchanger/logger.py | 26 ++ .../deskchanger/timer.py | 47 +++ .../deskchanger/wallpapers.py | 236 +++++++++++++ 6 files changed, 628 insertions(+), 1 deletion(-) create mode 100644 desk-changer@eric.gach.gmail.com/deskchanger/__init__.py create mode 100644 desk-changer@eric.gach.gmail.com/deskchanger/application.py create mode 100644 desk-changer@eric.gach.gmail.com/deskchanger/logger.py create mode 100644 desk-changer@eric.gach.gmail.com/deskchanger/timer.py create mode 100644 desk-changer@eric.gach.gmail.com/deskchanger/wallpapers.py diff --git a/.gitignore b/.gitignore index 5a6d470..91b6751 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ /.idea/ /.nbproject/private/ /.settings -/desk-changer@eric.gach.gmail.com.zip \ No newline at end of file +/desk-changer@eric.gach.gmail.com.zip +*.pyc +*.pyo diff --git a/desk-changer@eric.gach.gmail.com/deskchanger/__init__.py b/desk-changer@eric.gach.gmail.com/deskchanger/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/desk-changer@eric.gach.gmail.com/deskchanger/application.py b/desk-changer@eric.gach.gmail.com/deskchanger/application.py new file mode 100644 index 0000000..7425a75 --- /dev/null +++ b/desk-changer@eric.gach.gmail.com/deskchanger/application.py @@ -0,0 +1,316 @@ +from gi import require_version +from gi.repository import GLib, Gio, GObject +from gi._gi import variant_type_from_string +from . import logger +from .timer import HourlyTimer, IntervalTimer +from .wallpapers import Profile + +require_version('Gio', '2.0') +DeskChangerDaemonDBusInterface = Gio.DBusNodeInfo.new_for_xml(''' + + + + + + + + + + + + + + + + + + + + + + + + +''') + + +class Daemon(Gio.Application): + __version__ = '2.3.0-dev' + + def __init__(self, **kwargs): + Gio.Application.__init__(self, **kwargs) + self.add_main_option('version', ord('v'), GLib.OptionFlags.NONE, GLib.OptionArg.NONE, + 'Show the current daemon version and exit', None) + # We use this as our DBus interface name also + self.set_application_id('org.gnome.Shell.Extensions.DeskChanger.Daemon') + self.set_flags(Gio.ApplicationFlags.IS_SERVICE | Gio.ApplicationFlags.HANDLES_COMMAND_LINE) + self._dbus_id = None + # lockscreen mode + self._lockscreen = False + # setup the settings keys we need + self._background = Gio.Settings.new('org.gnome.desktop.background') + self._screensaver = Gio.Settings.new('org.gnome.desktop.screensaver') + self._settings = Gio.Settings.new('org.gnome.shell.extensions.desk-changer') + self._settings_handlers = [] + # Setup the profiles + self._desktop_profile = None + """:type: Profile""" + self._desktop_profile_handler = None + self._lockscreen_profile = None + """:type: Profile""" + self._lockscreen_profile_handler = None + # now finally, the timer + self._timer = None + + def change(self, reverse=False, history=True): + func = 'prev' if reverse else 'next' + update_lockscreen = self._settings.get_boolean('update-lockscreen') + if self._lockscreen and self._lockscreen_profile and update_lockscreen: + # Only change the lock screen profile here because + current = self._screensaver.get_string('picture-uri') if history else None + wallpaper = getattr(self._lockscreen_profile, func)(current) + self._screensaver.set_string('picture-uri', wallpaper) + else: + current = self._background.get_string('picture-uri') if history else None + wallpaper = getattr(self._desktop_profile, func)(current) + if update_lockscreen: + self._screensaver.set_string('picture-uri', wallpaper) + self._background.set_string('picture-uri', wallpaper) + self._emit_changed(wallpaper) + return wallpaper + + def do_activate(self): + logger.debug('::activate') + self._toggle_timer(self._settings.get_string('rotation')) + # Since we're a service, we have to increase the hold count to stay running + self.hold() + + def do_dbus_register(self, connection, object_path): + """Register the application on the DBus, if this fails, the application cannot run + + :param connection: The DBus connection for the application + :param object_path: The object path + :type connection: Gio.DBusConnection + :type object_path: str + :return: True if successful, False if not + :rtype: bool + """ + logger.debug('::dbus_register') + Gio.Application.do_dbus_register(self, connection, object_path) + try: + self._dbus_id = connection.register_object( + object_path, + DeskChangerDaemonDBusInterface.interfaces[0], + self._handle_dbus_call, + None, + None + ) + except GLib.Error as e: + logger.debug(e.args) + finally: + if self._dbus_id is None or self._dbus_id == 0: + logger.critical('failed to register DBus name %s', object_path) + return False + + logger.info('successfully registered DBus name %s', object_path) + return True + + def do_dbus_unregister(self, connection, object_path): + """Remove the application from the DBus, this happens after shutdown + + :param connection: + :param object_path: + :type connection: Gio.DBusConnection + :type object_path: str + """ + logger.debug('::dbus_unregister') + Gio.Application.do_dbus_unregister(self, connection, object_path) + if self._dbus_id is not None: + logger.info('removing DBus registration for name %s', object_path) + connection.unregister_object(self._dbus_id) + self._dbus_id = None + + def do_handle_local_options(self, options): + o = options.end().unpack() + if 'version' in o and o['version']: + print('%s: %s' % (__file__, Daemon.__version__)) + return 0 + return Gio.Application.do_handle_local_options(self, options) + + def do_startup(self): + """Startup method of application, get everything setup and ready to run here""" + logger.debug('::startup') + Gio.Application.do_startup(self) + action = Gio.SimpleAction.new('quit', None) + action.connect('activate', self.quit) + self.add_action(action) + # Load the current profiles + try: + self.load_profile(self._settings.get_string('current-profile')) + if self._settings.get_string('lockscreen-profile') != "": + self.load_profile(self._settings.get_string('lockstring-profile'), True) + except ValueError as e: + # If we failed to load the profile, its bad + logger.error('failed to load profiles on startup: %s', e.message) + # Connect the settings signals + self._settings_handlers.append(self._settings.connect('changed::rotation', + lambda s, k: self._toggle_timer(self._settings.get_string('rotation')))) + self._settings_handlers.append(self._settings.connect('changed::interval', + lambda s, k: self._toggle_timer(self._settings.get_string('rotation')))) + self._settings_handlers.append(self._settings.connect('changed::current-profile', + lambda s, k: self.load_profile(s.get_string(k)))) + self._settings_handlers.append(self._settings.connect('changed::lockscreen-profile', + lambda s, k: self.load_profile(s.get_string(k), True))) + # just because we're a service... activate is not called. can someone actually help me understand this? + # https://git.gnome.org/browse/glib/tree/gio/gapplication.c?h=2.50.0#n1023 + self.activate() + + def do_shutdown(self): + logger.debug('::shutdown') + # save state + if self._settings.get_boolean('remember-profile-state'): + self._desktop_profile.save_state(self._background.get_string('picture-uri')) + if self._lockscreen_profile is not None: + self._lockscreen_profile.save_state(self._screensaver.set_string('picture-uri')) + # disconnect signals + for handler_id in self._settings_handlers: + self._settings.disconnect(handler_id) + del self._desktop_profile + del self._lockscreen_profile + if self._timer: + del self._timer + self.release() + Gio.Application.do_shutdown(self) + + @GObject.Property(type=GObject.TYPE_STRV) + def history(self): + if self._lockscreen and self._lockscreen_profile: + return self._lockscreen_profile.history + else: + return self._desktop_profile.history + + def load_profile(self, name, lock_screen=False): + prop = '_desktop_profile' if lock_screen is False else '_lockscreen_profile' + handler = prop + '_handler' + if getattr(self, prop) is not None: + getattr(self, prop).disconnect(getattr(self, handler)) + delattr(self, prop) + setattr(self, prop, None) + try: + setattr(self, prop, Profile(name, self._settings)) + setattr(self, handler, getattr(self, prop).connect('preview', lambda _, uri: self._emit_preview(uri))) + except ValueError as e: + logger.critical('failed to load profile %s', name) + raise e + if self._settings.get_boolean('auto-rotate'): + # Specifically disable the history here. It's the first load, we don't care. + self.change(False, False) + + @GObject.Property(type=GObject.TYPE_STRV) + def queue(self): + if self._lockscreen and self._lockscreen_profile: + return self._lockscreen_profile.queue + else: + return self._desktop_profile.queue + + def _emit(self, signal, value): + logger.debug('[DBUS]::%s %s', signal, value) + self.get_dbus_connection().emit_signal( + None, + self.get_dbus_object_path(), + self.get_application_id(), + signal, + value + ) + + def _emit_changed(self, uri): + value = GLib.VariantBuilder.new(variant_type_from_string('r')) + value.add_value(GLib.Variant.new_string(uri)) + self._emit('changed', value.end()) + + def _emit_error(self, message): + value = GLib.VariantBuilder.new(variant_type_from_string('r')) + value.add_value(GLib.Variant.new_string(message)) + self._emit('error', value.end()) + + def _emit_preview(self, uri): + value = GLib.VariantBuilder.new(variant_type_from_string('r')) + value.add_value(GLib.Variant.new_string(uri)) + self._emit('preview', value.end()) + + def _handle_dbus_call(self, connection, sender, object_path, interface_name, method_name, parameters, invocation): + logger.debug('[DBUS] %s:%s', interface_name, method_name) + if interface_name == 'org.freedesktop.DBus.Properties': + if method_name == 'GetAll': + values = { + 'history': GLib.Variant('as', self.history), + 'queue': GLib.Variant('as', self.queue), + } + invocation.return_value(GLib.Variant('(a{sv})', (values,))) + elif method_name == 'Get': + interface_name, property_name = parameters.unpack() + if property_name == 'history': + invocation.return_value(GLib.Variant('(v)', (GLib.Variant('as', self.history),))) + elif property_name == 'queue': + invocation.return_value(GLib.Variant('(v)', (GLib.Variant('as', self.queue),))) + else: + invocation.return_dbus_error('org.freedesktop.DBus.Error.InvalidArgs', + 'Unknown property %s for interface %s' % ( + property_name, interface_name)) + logger.warning('[DBUS] Unkown propety %s for interface %s', property_name, interface_name) + elif method_name == 'Set': + interface_name, property_name, value = parameters.unpack() + if property_name == 'lockscreen': + self._lockscreen = bool(value) + invocation.return_value(GLib.Variant('()', tuple())) + logger.info('extension is in %s mode', 'lockscreen' if self._lockscreen else 'desktop') + else: + invocation.return_dbus_error('org.freedesktop.DBus.Error.InvalidArgs', + 'Unknown property %s for interface %s' % ( + property_name, interface_name)) + logger.warning('[DBUS] Unknown property for interface %s', property_name, interface_name) + else: + logger.warning('Missed call from %s for method %s', interface_name, method_name) + return + elif interface_name != self.get_application_id(): + logger.warning('received invalid dbus request for interface %s', interface_name) + return + + if method_name == 'Quit': + invocation.return_value(None) + self.quit() + elif method_name == 'LoadProfile': + profile, = parameters.unpack() + try: + self.load_profile(profile) + invocation.return_value(None) + except ValueError as e: + invocation.return_dbus_error(self.get_application_id() + '.LoadProfile', str(e.args)) + elif method_name == 'Next': + try: + invocation.return_value(GLib.Variant('(s)', (self.change(),))) + except Exception as e: + invocation.return_dbus_error(self.get_application_id() + '.Next', str(e.args)) + elif method_name == 'Prev': + try: + invocation.return_value(GLib.Variant('(s)', (self.change(True),))) + except ValueError as e: + invocation.return_dbus_error(self.get_application_id() + '.Prev', str(e.args)) + else: + logger.info('[DBUS] Method %s in %s does not exist', method_name, interface_name) + invocation.return_dbus_error('org.freedesktop.DBus.Error.UnknownMethod', + 'Method %s in %s does not exist' % (method_name, interface_name)) + return + + def _timer_callback(self): + return bool(self.change()) + + def _toggle_timer(self, rotation_type): + if self._timer is not None: + del self._timer + self._timer = None + + if rotation_type == 'interval': + self._timer = IntervalTimer(self._settings.get_int('interval'), self._timer_callback) + elif rotation_type == 'hourly': + self._timer = HourlyTimer(self._timer_callback) diff --git a/desk-changer@eric.gach.gmail.com/deskchanger/logger.py b/desk-changer@eric.gach.gmail.com/deskchanger/logger.py new file mode 100644 index 0000000..713af7d --- /dev/null +++ b/desk-changer@eric.gach.gmail.com/deskchanger/logger.py @@ -0,0 +1,26 @@ +from gi.repository import GLib + + +def critical(message, *args): + log(GLib.LogLevelFlags.LEVEL_CRITICAL, message, *args) + + +def error(message, *args): + log(GLib.LogLevelFlags.LEVEL_ERROR, message, *args) + + +def debug(message, *args): + log(GLib.LogLevelFlags.LEVEL_DEBUG, message, *args) + + +def info(message, *args): + log(GLib.LogLevelFlags.LEVEL_INFO, message, *args) + + +def log(level, message, *args): + message = str(message) % args + GLib.log_default_handler(None, level, message) + + +def warning(message, *args): + log(GLib.LogLevelFlags.LEVEL_WARNING, message, *args) diff --git a/desk-changer@eric.gach.gmail.com/deskchanger/timer.py b/desk-changer@eric.gach.gmail.com/deskchanger/timer.py new file mode 100644 index 0000000..02b959b --- /dev/null +++ b/desk-changer@eric.gach.gmail.com/deskchanger/timer.py @@ -0,0 +1,47 @@ +from abc import ABCMeta +from datetime import datetime +from gi.repository import GLib +from . import logger + + +class Timer(object): + __metaclass__ = ABCMeta + + def __init__(self, interval, callback): + self._callback = callback + self._interval = interval + self._source_id = GLib.timeout_add_seconds(interval, self.__callback__) + logger.debug('added timer for %d seconds', self._interval) + + def __del__(self): + if self._source_id: + logger.debug('removing old timer %d', self._source_id) + GLib.source_remove(self._source_id) + + def __callback__(self): + if not callable(self._callback): + logger.critical('callback for timer is not callable') + return False + return bool(self._callback()) + + +class HourlyTimer(Timer): + def __init__(self, callback): + self._did_hourly = False + super(HourlyTimer, self).__init__(5, callback) + + def __callback__(self): + d = datetime.utcnow() + if d.minute == 0 and d.second < 10: + if not self._did_hourly: + # This should trigger once per hour, right around the beginning of the hour... I hope... I tried to + # account for it not being accurately 5 second intervals + self._did_hourly = True + return super(HourlyTimer, self).__callback__() + return True + self._did_hourly = False + return True + + +class IntervalTimer(Timer): + pass diff --git a/desk-changer@eric.gach.gmail.com/deskchanger/wallpapers.py b/desk-changer@eric.gach.gmail.com/deskchanger/wallpapers.py new file mode 100644 index 0000000..f5b1be8 --- /dev/null +++ b/desk-changer@eric.gach.gmail.com/deskchanger/wallpapers.py @@ -0,0 +1,236 @@ +from gi.repository import GLib, Gio, GObject +from hashlib import sha256 +import random +from . import logger + +ACCEPTED = ['application/xml', 'image/jpeg', 'image/png'] + + +class Profile(GObject.GObject): + __gsignals__ = { + 'preview': (GObject.SIGNAL_RUN_LAST, GObject.TYPE_NONE, (GObject.TYPE_STRING, )) + } + + def __init__(self, name, settings, auto_load=True): + GObject.GObject.__init__(self) + self._name = name + self._settings = settings + # initialize variables + self._hash = None + self._history = [] + self._monitors = [] + self._position = 0 + self._queue = [] + self._wallpapers = [] + if auto_load and self.load() is False: + raise ValueError('unable to load %s' % (name,)) + self._handler_profiles = self._settings.connect('changed::profiles', lambda s, k: self.load()) + self._handler_random = self._settings.connect('changed::random', self._changed_random) + + def __del__(self): + self._remove_monitors() + self._settings.disconnect(self._handler_profiles) + self._settings.disconnect(self._handler_random) + + def emit(self, signal, *args): + logger.debug('%s::%s %s', str(self), signal, str(args)) + GObject.GObject.emit(self, signal, *args) + + @GObject.Property(type=GObject.TYPE_STRV) + def history(self): + """ + + :return: Current history of profile + :rtype: list + """ + return self._history + + def load(self): + # First clear everything out + self._remove_monitors() + self._history = [] + self._position = 0 + self._queue = [] + self._wallpapers = [] + # Now grab the + items = self._settings.get_value('profiles').unpack().get(self._name) + self._hash = sha256(str(items)) + if items is None: + logger.critical('failed to load profile %s because it does not exist', self._name) + return False + for (uri, recursive) in items: + logger.debug('loading %s for profile %s%s', uri, self._name, ' recursively' if recursive else '') + try: + location = Gio.File.new_for_uri(uri) + self._load_profile_location(location, recursive, True) + except GLib.Error as e: + logger.warning('failed to load %s for profile %s: %s', uri, self._name, str(e.args)) + if len(self._wallpapers) == 0: + logger.critical('no wallpapers were loaded for profile %s - wallpaper will not change', self._name) + # TODO - customize exception + raise ValueError('no wallpapers were loaded for profile %s' % (self._name,)) + if len(self._wallpapers) < 100: + logger.warning('available total wallpapers is under 100 (%d) - strict random checking is disabled', + len(self._wallpapers)) + self._wallpapers.sort() + if self._settings.get_boolean('remember-profile-state'): + self.restore_state() + self._load_next() + logger.info('profile %s has been loaded with %d wallpapers', self._name, len(self._wallpapers)) + + @GObject.Property(type=str) + def name(self): + return self._name + + def next(self, current=None): + if len(self._wallpapers) == 0: + logger.critical('no wallpapers are currently available for %s', self.name) + # TODO - customize exception + raise ValueError('no wallpapers are currently available for %s' % (self.name,)) + wallpaper = self._queue.pop(0) + if current: + self._history.append(current) + self._load_next() + self.emit('preview', self._queue[0]) + return wallpaper + + def prev(self, current=None): + if len(self._wallpapers) == 0: + logger.critical('no wallpapers are currently available for %s', self.name) + # TODO - customize exception + raise ValueError('no wallpapers are currently available for %s' % (self.name,)) + if len(self._history) == 0: + return False + wallpaper = self._history.pop(0) + if current: + self._queue.insert(0, current) + self.emit('preview', self._queue[0]) + return wallpaper + + @GObject.Property(type=GObject.TYPE_STRV) + def queue(self): + """ + + :return: Current queue of profile + :rtype: list + """ + return self._queue + + def restore_state(self): + logger.info('restoring state of profile %s', self.name) + states = dict(self._settings.get_value('profile-state').unpack()) + if self.name not in states: + logger.debug('no previous state for %s', self.name) + return + self._queue = list(states[self.name]) + del states[self.name] + self._settings.set_value('profile-state', GLib.Variant('a{s(ss)}', states)) + + def save_state(self, current): + logger.debug('saving state of profile %s', self.name) + states = dict(self._settings.get_value('profile-state').unpack()) + if len(self._queue) == 0: + logger.critical('failed to save the state of %s: no wallpapers available', self.name) + if self.name in states: + logger.warning('overwriting existing state for %s', self.name) + states[self.name] = (current, self._queue[0]) + self._settings.set_value('profile-state', GLib.Variant('a{s(ss)}', states)) + + def _changed_profiles(self): + if self._hash == sha256(str(self._settings.get_value('profiles').unpack().get(self.name))): + logger.debug('profile is identical, not forcing a reload') + return + self.load() + self.emit('preview', self._queue[0]) + + def _changed_random(self): + self._load_next(True) + self.emit('preview', self._queue[0]) + + def _files_changed(self, monitor, _file, other_file, event_type): + logger.debug('file monitor %s changed with event type %s', _file.get_uri(), event_type) + if event_type == Gio.FileMonitorEvent.CREATED and _file.get_file_type() in ACCEPTED: + try: + self._wallpapers.index(_file.get_uri()) + except ValueError: + logger.debug('adding new wallpaper %s', _file.get_uri()) + self._wallpapers.append(_file.get_uri()) + self._wallpapers.sort() + elif event_type == Gio.FileMonitorEvent.DELETED: + try: + i = self._wallpapers.index(_file.get_uri()) + logger.debug('removing deleted file %s from the list', _file.get_uri()) + self._wallpapers.pop(i) + self._wallpapers.sort() + except ValueError: + pass + + def _load_next(self, clear=False): + if clear: + self._queue = [] + logger.info('queue forcibly cleared') + if len(self._queue) > 0: + logger.info('there are already %d wallpapers in the queue, skipping', len(self._queue)) + return + wallpaper = None + if self._settings.get_boolean('random'): + while wallpaper is None: + wallpaper = self._wallpapers[random.randint(0, (len(self._wallpapers) - 1))] + logger.debug("got %s as a possible next wallpaper", wallpaper) + if len(self._wallpapers) > 100: + if self._history.count(wallpaper) > 0: + logger.debug("%s has already been shown recently, choosing another wallpaper", wallpaper) + wallpaper = None + elif self._queue.count(wallpaper) > 0: + logger.debug("%s is already in the queue, choosing another wallpaper", wallpaper) + wallpaper = None + elif (len(self._history) > 0 and wallpaper == self._history[0]) or ( + len(self._queue) > 0 and wallpaper == self._queue[0]): + logger.info("%s is too similar, grabbing a different one", wallpaper) + wallpaper = None + else: + if self._position >= len(self._wallpapers): + logger.debug('reached end of wallpapers, resetting counter') + self._position = 0 + wallpaper = self._wallpapers[self._position] + self._queue.append(wallpaper) + logger.info('adding %s to the queue', wallpaper) + + def _load_profile_children(self, location, recursive): + try: + enumerator = location.enumerate_children('standard::*', Gio.FileQueryInfoFlags.NONE, None) + except GLib.Error as e: + logger.warning('failed to load children for location %s: %s', location.get_uri(), str(e.args)) + return + for info in enumerator: + child = location.resolve_relative_path(info.get_name()) + if child is None: + logger.critical('failed to load %s', info.get_name()) + continue + self._load_profile_location(child, recursive) + + def _load_profile_location(self, location, recursive, toplevel=False): + try: + info = location.query_info('standard::*', Gio.FileQueryInfoFlags.NONE, None) + except GLib.Error as e: + logger.warning('failed to load location %s: %s', location.get_uri(), str(e.args)) + return + if info.get_file_type() == Gio.FileType.DIRECTORY: + if recursive or toplevel: + monitor = location.monitor_directory(Gio.FileMonitorFlags.NONE, Gio.Cancellable()) + logger.debug('adding %s as directory to watch', location.get_uri()) + monitor.connect('changed', self._files_changed) + self._monitors.append(monitor) + logger.debug('descending into %s to find wallpapers', location.get_uri()) + self._load_profile_children(location, recursive) + elif info.get_file_type() == Gio.FileType.REGULAR and info.get_content_type() in ACCEPTED: + logger.debug('adding wallpaper %s', location.get_uri()) + self._wallpapers.append(location.get_uri()) + + def _remove_monitors(self): + for monitor in self._monitors: + monitor.cancel() + self._monitors = [] + + +GObject.type_register(Profile)