borgend/ui.py

changeset 80
a409242121d5
parent 79
b075b3db3044
child 84
b613265bd679
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/borgend/ui.py	Sun Jan 28 11:54:46 2018 +0000
@@ -0,0 +1,311 @@
+#
+# Borgend MacOS UI
+#
+
+import rumps
+import time
+import datetime
+import objc
+from threading import Lock, Timer
+
+from . import loggers
+from . import backup
+from . import dreamtime
+from . import branding
+from .config import settings
+
+logger=loggers.get(__name__)
+
+traynames_ok={
+    backup.State.INACTIVE: '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 make_title(status):
+    def add_info(info, new):
+        if info:
+            return "%s; %s" % (info, new)
+        else:
+            return new
+
+    info=None
+    this_need_reconstruct=None
+
+    if not status.errors.ok():
+        info=add_info(info, str(status.errors))
+
+    if status.state==backup.State.SCHEDULED:
+        # Operation scheduled
+        when=status.when()
+        now=time.time()
+
+        if when<now:
+            whenstr='overdue'
+            info=''
+        else:
+            tnow=datetime.datetime.fromtimestamp(now)
+            twhen=datetime.datetime.fromtimestamp(when)
+            tendtoday=twhen.replace(hour=23,minute=59,second=59)
+            tendtomorrow=tendtoday+datetime.timedelta(days=1)
+            diff=datetime.timedelta(seconds=when-now)
+
+            if twhen>tendtomorrow:
+                whenday=datetime.date.fromtimestamp(when)
+                whenstr='on %s' % twhen.date().isoformat()
+                this_need_reconstruct=tendtoday+datetime.timedelta(seconds=1)
+            elif diff.seconds>=12*60*60: # 12 hours
+                whenstr='tomorrow'
+                this_need_reconstruct=twhen-datetime.timedelta(hours=12)
+            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)
+
+            this_info=''
+            if 'reason' in status.detail:
+                this_info=status.detail['reason'] + ' '
+
+            when_how_sched= "%s%s %s" % (this_info, status.operation, whenstr)
+
+            info=add_info(info, when_how_sched)
+
+    elif status.state==backup.State.QUEUED:
+        info=add_info(info, "queued")
+    elif status.state==backup.State.ACTIVE:
+        # Operation running
+        progress=''
+        d=status.detail
+        if 'progress_current' in d and 'progress_total' in d:
+            progress=' %d%%' % (d['progress_current']/d['progress_total'])
+        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.operation, 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_need_reconstruct
+
+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, errors=None, _index=index:
+                self.__status_callback(_index, status, errorlog=errors))
+            backups[index].set_status_update_callback(cb)
+
+        dreamtime.add_callback(self, self.refresh_ui)
+
+    def __rebuild_menu(self):
+        menu=[]
+        state=(backup.State.INACTIVE, backup.Errors.OK)
+        need_reconstruct=None
+        for index in range(len(self.backups)):
+            b=self.backups[index]
+            title, this_state, this_need_reconstruct=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)
+
+            # Do we have to automatically update menu display?
+            if not need_reconstruct:
+                need_reconstruct=this_need_reconstruct
+            elif this_need_reconstruct:
+                need_reconstruct=min(need_reconstruct, this_need_reconstruct)
+
+        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, need_reconstruct
+
+    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, need_reconstruct=self.__rebuild_menu()
+        title=trayname(state)
+
+        if need_reconstruct:
+            when=time.mktime(need_reconstruct.timetuple())
+            delay=when-time.time()
+            self.refresh_timer=Timer(delay, self.refresh_ui)
+            self.refresh_timer_time=need_reconstruct
+            self.refresh_timer.start()
+
+        return menu, title
+
+    def refresh_ui(self):
+        with self.lock:
+            menu, title=self.build_menu_and_timer()
+            self.menu.clear()
+            self.menu.update(menu)
+            self.title=title
+
+    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'])
+
+    def quit(self):
+        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(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=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