Sun, 05 Dec 2021 21:08:01 +0200
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()