borgend/ui.py

Wed, 07 Feb 2018 20:39:01 +0000

author
Tuomo Valkonen <tuomov@iki.fi>
date
Wed, 07 Feb 2018 20:39:01 +0000
changeset 113
6993964140bd
parent 112
173d9d7048b6
child 114
ad9fb3dd9fec
permissions
-rw-r--r--

Time snapshot fixes.
Python's default arguments are purely idiotic (aka. pythonic): generated
only once. This makes sense in a purely functional language, which Python
lightyears away from, but severely limits their usefulness in an imperative
language. Decorators also seem clumsy for this, as one would have to tell
the number of positional arguments for things to work nice, being able to
pass the snapshot both positionally and as keyword. No luck.
So have to do things the old-fashioned hard way.

#
# Borgend by Tuomo Valkonen, 2018
#
# Borgend MacOS tray icon UI
#

import rumps
import time
import datetime
import logging
import objc
import math
from threading import Lock, Timer

from . import backup
from . import dreamtime
from . import branding
from . import loggers
from .exprotect import protect_noreturn
from .config import settings

logger=logging.getLogger(__name__)

traynames_ok={
    backup.State.INACTIVE: 'B.',
    backup.State.PAUSED: 'B‖',
    backup.State.SCHEDULED: 'B.',
    backup.State.QUEUED: 'B:',
    backup.State.ACTIVE: 'B!',
}

traynames_errors={
    # The first one should never be used
    backup.Errors.OK: traynames_ok[backup.State.INACTIVE],
    backup.Errors.BUSY: 'B⦙',
    backup.Errors.OFFLINE: 'B⦙',
    backup.Errors.ERRORS: 'B?'
}

def trayname(ste):
    state=ste[0]
    errors=ste[1]
    if not errors.ok():
        return traynames_errors[errors]
    else:
        return traynames_ok[state]

def combine_state(a, b):
    return (max(a[0], b[0]), max(a[1], b[1]))

# Refresh the menu at most once a second to reduce flicker
refresh_interval=1.0

# Workaround to rumps brokenness;
# see https://github.com/jaredks/rumps/issues/59
def notification_workaround(title, subtitle, message):
    try:
        NSDictionary = objc.lookUpClass("NSDictionary")
        d=NSDictionary()

        rumps.notification(title, subtitle, message, data=d)
    except Exception as err:
        logger.exception("Failed to display notification")

# Based on code snatched from
# https://stackoverflow.com/questions/12523586/python-format-size-application-converting-b-to-kb-mb-gb-tb/37423778
def humanbytes(B):
   'Return the given bytes as a human friendly KB, MB, GB, or TB string'
   B = float(B)
   KB = float(1024)
   MB = float(KB ** 2) # 1,048,576
   GB = float(KB ** 3) # 1,073,741,824
   TB = float(KB ** 4) # 1,099,511,627,776

   if B < KB:
      return '{0}B'.format(B)
   elif KB <= B < MB:
      return '{0:.2f}KB'.format(B/KB)
   elif MB <= B < GB:
      return '{0:.2f}MB'.format(B/MB)
   elif GB <= B < TB:
      return '{0:.2f}GB'.format(B/GB)
   elif TB <= B:
      return '{0:.2f}TB'.format(B/TB)


def progress_percentage(done, total, d):
    percentage = 100*float(done)/float(total)
    progress=': %d%%' % int(round(percentage))
    if 'operation_no' in d:
        progress=':#%d%s' % (d['operation_no'], progress)
    return progress

def progress_parts(done, total, d):
    progress=': %d/%d' % (int(done), int(total))
    if 'operation_no' in d:
        progress=':#%d%s' % (d['operation_no'], progress)
    return progress

_error_state=(backup.State.INACTIVE, backup.Errors.ERRORS)

def make_title(status):
    def add_info(info, new):
        if info:
            return "%s; %s" % (info, new)
        else:
            return new

    info=None
    this_refresh_time=None

    if not status.errors.ok():
        info=add_info(info, str(status.errors))

    if status.state==backup.State.PAUSED:
        info=add_info(info, "paused")
    elif status.state==backup.State.SCHEDULED:
        # Operation scheduled
        when=status.when()
        now=time.time()

        if when==math.inf:
            whenstr='--'
        else:
            tnow=datetime.datetime.fromtimestamp(now)
            twhen=datetime.datetime.fromtimestamp(when)
            tendtoday=tnow.replace(hour=23,minute=59,second=59)
            tendtomorrow=tendtoday+datetime.timedelta(days=1)

            if twhen<tnow:
                whenstr='overdue' + (' on %s' % twhen.isoformat())
            elif twhen>tendtomorrow:
                # Display date if scheduled event is after tomorrow
                whenday=datetime.date.fromtimestamp(when)
                whenstr='on %s' % twhen.date().isoformat()
                this_refresh_time=tendtoday+datetime.timedelta(seconds=1)
            elif twhen>tendtoday and when-now>=12*60*60: # 12 hours
                # Display 'tomorrow' if the scheduled event is tomorrow and
                # not earlier than after 12 hours
                whenstr='tomorrow'
                this_refresh_time=twhen-datetime.timedelta(hours=12)
            else:
                # Otherwise, display time
                twhen=time.localtime(when)
                if twhen.tm_sec>30:
                    # Round up minute display to avoid user confusion
                    twhen=time.localtime(when+30)
                whenstr='at %02d:%02d' % (twhen.tm_hour, twhen.tm_min)

        this_info=''
        if 'reason' in status.detail:
            this_info=status.detail['reason'] + ' '

        when_how_sched= "%s%s %s" % (this_info, status.type, whenstr)

        info=add_info(info, when_how_sched)

    elif status.state==backup.State.QUEUED:
        info=add_info(info, "queued %s" % status.type)
    elif status.state==backup.State.ACTIVE:
        # Operation running
        progress=''
        d=status.detail
        if 'progress_current' in d and 'progress_total' in d:
            progress=progress_percentage(d['progress_current'],
                                         d['progress_total'], d)
        elif ('progress_current_secondary' in d and
              'progress_total_secondary' in d):
            progress=progress_parts(d['progress_current_secondary'],
                                    d['progress_total_secondary'], d)
        elif 'original_size' in d and 'deduplicated_size' in d:
            progress=' %s→%s' % (humanbytes(d['original_size']),
                                 humanbytes(d['deduplicated_size']))

        howrunning = "running %s%s" % (status.type, progress)

        info=add_info(info, howrunning)
    else:
        pass

    if info:
        title=status.name + ' (' + info + ')'
    else:
        title=status.name

    return title, (status.state, status.errors), this_refresh_time

class BorgendTray(rumps.App):
    def __init__(self, backups):
        self.lock=Lock()
        self.backups=backups
        self.refresh_timer=None
        self.refresh_timer_time=None
        self.statuses=[None]*len(backups)

        for index in range(len(backups)):
            self.statuses[index]=backups[index].status()

        menu, title=self.build_menu_and_timer()

        super().__init__(title, menu=menu, quit_button=None)

        for index in range(len(backups)):
            # Python closures suck dog's balls; hence the _index=index hack
            # See also http://math.andrej.com/2009/04/09/pythons-lambda-is-broken/
            cb=(lambda status, errorlog=None, _index=index:
                self.__status_callback(_index, status, errorlog=errorlog))
            backups[index].set_status_update_callback(cb)

        dreamtime.add_callback(self, self.__sleepwake_callback)

    def __rebuild_menu(self):
        menu=[]
        state=(backup.State.INACTIVE, backup.Errors.OK)
        refresh_time=None
        all_paused=True

        for index in range(len(self.backups)):
            b=self.backups[index]
            title, this_state, this_refresh_time=make_title(self.statuses[index])
            # Python closures suck dog's balls...
            # first and the last program I write in Python until somebody
            # fixes this brain damage
            cbm=lambda sender, _b=b: self.__menu_select_backup(sender, _b)
            item=rumps.MenuItem(title, callback=cbm)
            if not this_state[1].ok():
                item.state=-1
            elif this_state[0]==backup.State.SCHEDULED or this_state[0]==backup.State.QUEUED:
                item.state=1
            menu.append(item)
            state=combine_state(state, this_state)

            all_paused=(all_paused and this_state[0]==backup.State.PAUSED)

            # Do we have to automatically update menu display?
            if not refresh_time:
                refresh_time=this_refresh_time
            elif this_refresh_time:
                refresh_time=min(refresh_time, this_refresh_time)

        menu_log=rumps.MenuItem("Show log", callback=lambda _: showlog())
        menu.append(menu_log)

        if all_paused:
            pausename='Resume all'
        else:
            pausename="Pause all"
        menu_pause=rumps.MenuItem(pausename, callback=lambda _: self.pause_resume_all())
        menu.append(menu_pause)

        if not settings['no_quit_menu_entry']:
            menu_quit=rumps.MenuItem("Quit...", callback=lambda _: self.quit())
            menu.append(menu_quit)

        return menu, state, refresh_time

    def build_menu_and_timer(self):
        if self.refresh_timer:
            self.refresh_timer.cancel()
            self.refresh_timer=None
            self.refresh_timer_time=None

        logger.debug('Rebuilding menu')
        menu, state, refresh_time=self.__rebuild_menu()
        title=trayname(state)

        if refresh_time:
            # Need to time a refresh due to content display changing,
            # e.g., 'tomorrow' changing to a more specific hour.
            when=time.mktime(refresh_time.timetuple())
            delay=when-time.time()
            if delay>0:
                logger.debug('Timing menu refresh in %s seconds' % delay)
                self.refresh_timer=Timer(delay, self.refresh_ui)
                self.refresh_timer_time=refresh_time
                self.refresh_timer.start()

        return menu, title

    # Callbacks -- exception-protected to get any indications of errors

    @protect_noreturn
    def refresh_ui(self):
        with self.lock:
            menu, title=self.build_menu_and_timer()
            self.menu.clear()
            self.menu.update(menu)
            self.title=title

    @protect_noreturn
    def __status_callback(self, index, status, errorlog=None):
        logger.debug("Tray status callback")
        with self.lock:
            self.statuses[index]=status
            # Time the refresh if it has not been timed, or if the timer
            # is timing for the "long-term" (refresh_timer_time set)
            if not self.refresh_timer or self.refresh_timer_time:
                # logger.debug("Timing refresh")
                self.refresh_timer=Timer(refresh_interval, self.refresh_ui)
                # refresh_timer_time is only set for "long-term timers"
                self.refresh_timer_time=None
                self.refresh_timer.start()

        if errorlog:
            if 'msgid' not in errorlog or not isinstance(errorlog['msgid'], str):
                msgid='UnknownError'
            else:
                msgid=errorlog['msgid']

            logger.debug("Opening notification for error %s '%s'",
                         msgid, errorlog['message'])

            notification_workaround(branding.appname_stylised,
                                    msgid, errorlog['message'])

    @protect_noreturn
    def pause_resume_all(self):
        with self.lock:
            try:
                all_paused=True
                for b in self.backups:
                    all_paused=all_paused and b.is_paused()
                if all_paused:
                    logger.debug('Pausing all')
                    for b in self.backups:
                        b.resume()
                else:
                    logger.debug('Resuming all')
                    for b in self.backups:
                        b.pause()
            except:
                logger.exception("Pause/resume error")

    @protect_noreturn
    def __sleepwake_callback(self, woke):
        if woke:
            self.refresh_ui()

    @protect_noreturn
    def quit(self):
        rumps.quit_application()

    @protect_noreturn
    def __menu_select_backup(self, sender, b):
        #sender.state=not sender.state
        logger.debug("Manually backup '%s'", b.name)
        try:
            b.create()
        except Exception as err:
            logger.exception("Failure to initialise backup")
            notification_workaround(branding.appname_stylised,
                                    err.__class__.__name__, str(err))

#
# Log window
#

logwindow=[None]
logwindow_lock=Lock()

def showlog():
    try:
        w=None
        with logwindow_lock:
            if not logwindow[0]:
                lines=loggers.fifo.formatAll()
                msg="\n".join(lines[0:])
                w=rumps.Window(title=branding.appname_stylised+' log',
                               default_text=msg,
                               ok='Close',
                               dimensions=(640,320))
                logwindow[0]=w
        if w:
            try:
                w.run()
            finally:
                with logwindow_lock:
                    logwindow[0]=None
    except Exception as err:
        logger.exception("Failed to display log")

#
# Notification click response => show log window
#

@rumps.notifications
def notification_center(_):
    showlog()

mercurial