--- /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() +