Sun, 04 Feb 2018 01:27:38 +0000
Part 2 of handling macOS sleep/wake signal brokenness.
a) Added Time.horizon(), which indicates how far the Time event is from the
epoch, in monotonic time. For DreamTime, if the system is sleeping, this
returns ∞. b) The DreamTime monitor also signals sleep to callbacks, so
that the Scheduler can re-sort the events. The sorting is now done by
the horizon, so DreamTime events will be moved last and not activated when
the system is sleeping or "sleeping", but other events will be executed
normally if the system is merely "sleeping".
# # Borgend by Tuomo Valkonen, 2018 # # Borgend MacOS tray icon UI # import rumps import time import datetime import logging import objc from threading import Lock, Timer from . import backup from . import dreamtime from . import branding from . import loggers 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): progress='' try: percentage = 100*float(done)/float(total) progress=': %d%%' % int(round(percentage)) if 'operation_no' in d: progress=':#%d%s' % (d['operation_no'], progress) except: pass return progress def progress_parts(done, total, d): progress='' try: progress=': %d/%d' % (int(done), int(total)) if 'operation_no' in d: progress=':#%d%s' % (d['operation_no'], progress) except: pass return progress 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.PAUSED: info=add_info(info, "paused") elif 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.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_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, 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) need_reconstruct=None all_paused=True 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) all_paused=(all_paused and this_state[0]==backup.State.PAUSED) # 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 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, 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 __sleepwake_callback(self, woke): if woke: self.refresh_ui() 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") 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=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()