ui.py

Mon, 22 Jan 2018 21:07:34 +0000

author
Tuomo Valkonen <tuomov@iki.fi>
date
Mon, 22 Jan 2018 21:07:34 +0000
changeset 53
442c558bd632
parent 51
1614b2184bb4
child 55
407af23d16bb
permissions
-rw-r--r--

Generalisation of scheduler thread to general queue threads

#
# Borgend MacOS UI
#

import rumps
import time
import datetime
import logging
import borgend
import backup
from threading import Lock, Timer
from config import settings
import objc

logger=borgend.logger.getChild(__name__)

traynames={
    backup.INACTIVE: 'B.',
    backup.SCHEDULED: 'B.',
    backup.ACTIVE: 'B!',
    backup.BUSY: 'B⦙',
    backup.OFFLINE: 'B⦙',
    backup.ERRORS: 'B?'
}

statestring={
    backup.INACTIVE: 'inactive',
    backup.SCHEDULED: 'scheduled',
    backup.ACTIVE: 'active',
    backup.BUSY: 'busy',
    backup.OFFLINE: 'offline',
    backup.ERRORS: 'errors'
}

# 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 make_title(status):
    state=status['state']
    detail=''
    if status['type']=='scheduled':
        # Operation scheduled
        when=status['when']
        now=time.time()
        if when<now:
            whenstr='overdue'
            detail=''
        else:
            diff=datetime.timedelta(seconds=when-now)
            if diff.days>0:
                whenday=datetime.date.fromtimestamp(when)
                whenstr='on %s' % whenday.isoformat()
            else:
                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)

            detail=''
            if state>=backup.BUSY and state in statestring:
                detail=statestring[state] + '; '
            if status['detail']!='normal':
                detail=detail+status['detail']+' '
        title="%s (%s%s %s)" % (status['name'], detail, status['operation'], whenstr)
    elif status['type']=='current':
        # Operation running
        progress=''
        if 'progress_current' in status and 'progress_total' in status:
            progress=' %d%%' % (status['progress_current']/status['progress_total'])
        elif 'original_size' in status and 'deduplicated_size' in status:
            progress=' %s→%s' % (humanbytes(status['original_size']),
                                 humanbytes(status['deduplicated_size']))
        title="%s (running: %s%s)" % (status['name'], status['operation'], progress)
    else: # status['type']=='nothing':
        # Should be unscheduled, nothing running
        detail=''
        if state>=backup.BUSY and state in statestring:
            detail=' (' + statestring[state] + ')'
        title=status['name'] + detail

    return title, state

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

        with self.lock:
            # Initialise reporting callbacks and local status copy
            # (since rumps doesn't seem to be able to update menu items
            # without rebuilding the whole menu, and we don't want to lock
            # when calling Backup.status(), especially as we will be called
            # from Backup reporting its status, we keep a copy to allow
            # rebuilding the entire menu
            for index in range(len(backups)):
                b=backups[index]
                # 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 obj, status, _index=index, errors=None:
                    self.__status_callback(obj, _index, status, errors))
                b.set_status_update_callback(cb)
                self.statuses[index]=b.status()

            menu, state=self.__rebuild_menu()

            self.refresh_timer=None

            super().__init__(traynames[state], menu=menu, quit_button=None)

    def __rebuild_menu(self):
        menu=[]
        state=backup.INACTIVE
        for index in range(len(self.backups)):
            b=self.backups[index]
            title, this_state=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 this_state==backup.SCHEDULED:
                item.state=1
            elif this_state>=backup.BUSY:
                item.state=-1
            menu.append(item)
            state=backup.combine_state(state, this_state)

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

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

        return menu, state

    def refresh_ui(self):
        with self.lock:
            self.refresh_timer=None
            logger.debug('Rebuilding menu')
            menu, active=self.__rebuild_menu()
            self.menu.clear()
            self.menu.update(menu)
            self.title=traynames[active]

    def __status_callback(self, obj, index, status, errorlog):
        logger.debug('Status callback: %s' % str(status))

        with self.lock:
            self.statuses[index]=status
            if self.refresh_timer==None:
                self.refresh_timer=Timer(refresh_interval, self.refresh_ui)
                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(borgend.appname_stylised,
                                    msgid, errorlog['message'])

    def quit(self):
        logging.shutdown()
        rumps.quit_application()

    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(borgend.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=borgend.fifolog.formatAll()
                msg="\n".join(lines[0:])
                w=rumps.Window(title=borgend.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