borgend/ui.py

Sun, 05 Dec 2021 21:08:01 +0200

author
Tuomo Valkonen <tuomov@iki.fi>
date
Sun, 05 Dec 2021 21:08:01 +0200
changeset 141
a1c97bc1789e
parent 133
ec8014a2ee7a
child 144
31227feaa05a
permissions
-rw-r--r--

Sleep wake callback hadn't been updated to menu refresh code changes. Fixed.

#
# 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
from Foundation import (NSDate, NSTimer, NSRunLoop, NSDefaultRunLoopMode)
from AppKit import (NSEventTrackingRunLoopMode)

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 twice a second
refresh_interval=0.5

# 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 EventTrackingTimer(rumps.Timer):
    """
    Variant of rumps.Timer changed to
    a) use *both* NSEventTrackingRunLoopMode (for when menu open) and
       NSDefaultRunLoopMode (for when menu closed),
    b) in the mainRunLoop, to allow creating timers there from other threads.
    """
    def start(self):
        """Start the timer thread loop."""
        if not self._status:
            self._nsdate = NSDate.date()
            self._nstimer = NSTimer.alloc().initWithFireDate_interval_target_selector_userInfo_repeats_(
                self._nsdate, self._interval, self, 'callback:', None, True)
            NSRunLoop.mainRunLoop().addTimer_forMode_(self._nstimer, NSEventTrackingRunLoopMode)
            NSRunLoop.mainRunLoop().addTimer_forMode_(self._nstimer, NSDefaultRunLoopMode)
            #rumps._TIMERS[self] = None
            self._status = True

class BorgendTray(rumps.App):
    def __init__(self, backups):
        self.lock=Lock()
        self.backups=backups
        self.refresh_timer=EventTrackingTimer(self.__refresh_callback, refresh_interval)
        self.updated_recently=False
        self.statuses=[None]*len(backups)

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

        itemlist, title=self.build_menu_and_timer()

        super().__init__(title, menu=itemlist, 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, update):
        if not update:
            self.itemlist=[]
        
        itemlist=self.itemlist
        
        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)
            itemstate=0
            if not this_state[1].ok():
                itemstate=-1
            elif this_state[0]==backup.State.SCHEDULED or this_state[0]==backup.State.QUEUED:
                itemstate=1
                
            if update:
                item=itemlist[index]
                if item.title!=title:
                    item.title=title
                if item.state!=itemstate:
                    item.state=itemstate
            else:
                item=rumps.MenuItem(title, callback=cbm)
                item.state=itemstate
                itemlist.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)

        index = len(self.backups)
        if all_paused:
            pausename='Resume all'
        else:
            pausename="Pause all"
        if update:
            item=itemlist[index]
            if item.title!=pausename:
                item.title=pausename
        else:
            menu_pause=rumps.MenuItem(pausename, callback=lambda _: self.pause_resume_all())
            itemlist.append(menu_pause)

        if not update:
            menu_log=rumps.MenuItem("Show log", callback=lambda _: showlog())
            itemlist.append(menu_log)
            if not settings['no_quit_menu_entry']:
                menu_quit=rumps.MenuItem("Quit...", callback=lambda _: self.quit())
                itemlist.append(menu_quit)
        
        return itemlist, state, refresh_time

    def build_menu_and_timer(self, update=False):
        logger.debug('Rebuilding menu')
        itemlist, state, refresh_time=self.__rebuild_menu(update)
        title=trayname(state)
        
        if update and self.title!=title:
            logger.debug("set title %s" % title)
            self.title=title

        if not self.updated_recently and not refresh_time:
            self.refresh_timer.stop()
        elif self.updated_recently:
            self.updated_recently=False
            if self.refresh_timer.interval>refresh_interval:
                self.refresh_timer.stop()
                self.refresh_timer.interval=refresh_interval
                self.refresh_timer.start()
        else:
            # 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=max(when-time.time(), refresh_interval)
            logger.debug('Timing menu refresh in %s seconds' % delay)
            self.refresh_timer.stop()
            self.refresh_timer.interval=delay
            self.refresh_timer.start()

        return itemlist, title

    def __schedule_refresh(self):
            self.updated_recently=True
            # Time the refresh if it has not been timed, or if the timer
            # is timing for the "long-term" (refresh_timer_time set)
            if self.refresh_timer.interval>refresh_interval:
                self.refresh_timer.stop()
                self.refresh_timer.interval=refresh_interval
            self.refresh_timer.start()

    # Callbacks -- exception-protected to get any indications of errors
    @protect_noreturn
    def __refresh_callback(self, timer):
        with self.lock:
            self.build_menu_and_timer(True)
 
    @protect_noreturn
    def __status_callback(self, index, status, errorlog=None):
        logger.debug("Tray status callback")
        with self.lock:
            self.statuses[index]=status
            self.__schedule_refresh()

        # 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:
            with self.lock:
                self.__schedule_refresh()

    @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