Sun, 05 Dec 2021 00:42:01 +0200
Need to time menu updates using Apple's frameworks to avoid segfaults.
However rumps.Timer doesn't work with menu updates while the menu is open, so
implement EventTrackingTimer class that uses NSEventTrackingRunLoopMode in
mainRunLoop.
# # 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 # 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.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() # 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: self.refresh_ui() @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()