| 1 # |
|
| 2 # Borgend MacOS UI |
|
| 3 # |
|
| 4 |
|
| 5 import rumps |
|
| 6 import time |
|
| 7 import datetime |
|
| 8 import objc |
|
| 9 from threading import Lock, Timer |
|
| 10 |
|
| 11 import loggers |
|
| 12 import backup |
|
| 13 import dreamtime |
|
| 14 import branding |
|
| 15 from config import settings |
|
| 16 |
|
| 17 logger=loggers.get(__name__) |
|
| 18 |
|
| 19 traynames_ok={ |
|
| 20 backup.State.INACTIVE: 'B.', |
|
| 21 backup.State.SCHEDULED: 'B.', |
|
| 22 backup.State.QUEUED: 'B:', |
|
| 23 backup.State.ACTIVE: 'B!', |
|
| 24 } |
|
| 25 |
|
| 26 traynames_errors={ |
|
| 27 # The first one should never be used |
|
| 28 backup.Errors.OK: traynames_ok[backup.State.INACTIVE], |
|
| 29 backup.Errors.BUSY: 'B⦙', |
|
| 30 backup.Errors.OFFLINE: 'B⦙', |
|
| 31 backup.Errors.ERRORS: 'B?' |
|
| 32 } |
|
| 33 |
|
| 34 def trayname(ste): |
|
| 35 state=ste[0] |
|
| 36 errors=ste[1] |
|
| 37 if not errors.ok(): |
|
| 38 return traynames_errors[errors] |
|
| 39 else: |
|
| 40 return traynames_ok[state] |
|
| 41 |
|
| 42 def combine_state(a, b): |
|
| 43 return (max(a[0], b[0]), max(a[1], b[1])) |
|
| 44 |
|
| 45 # Refresh the menu at most once a second to reduce flicker |
|
| 46 refresh_interval=1.0 |
|
| 47 |
|
| 48 # Workaround to rumps brokenness; |
|
| 49 # see https://github.com/jaredks/rumps/issues/59 |
|
| 50 def notification_workaround(title, subtitle, message): |
|
| 51 try: |
|
| 52 NSDictionary = objc.lookUpClass("NSDictionary") |
|
| 53 d=NSDictionary() |
|
| 54 |
|
| 55 rumps.notification(title, subtitle, message, data=d) |
|
| 56 except Exception as err: |
|
| 57 logger.exception("Failed to display notification") |
|
| 58 |
|
| 59 # Based on code snatched from |
|
| 60 # https://stackoverflow.com/questions/12523586/python-format-size-application-converting-b-to-kb-mb-gb-tb/37423778 |
|
| 61 def humanbytes(B): |
|
| 62 'Return the given bytes as a human friendly KB, MB, GB, or TB string' |
|
| 63 B = float(B) |
|
| 64 KB = float(1024) |
|
| 65 MB = float(KB ** 2) # 1,048,576 |
|
| 66 GB = float(KB ** 3) # 1,073,741,824 |
|
| 67 TB = float(KB ** 4) # 1,099,511,627,776 |
|
| 68 |
|
| 69 if B < KB: |
|
| 70 return '{0}B'.format(B) |
|
| 71 elif KB <= B < MB: |
|
| 72 return '{0:.2f}KB'.format(B/KB) |
|
| 73 elif MB <= B < GB: |
|
| 74 return '{0:.2f}MB'.format(B/MB) |
|
| 75 elif GB <= B < TB: |
|
| 76 return '{0:.2f}GB'.format(B/GB) |
|
| 77 elif TB <= B: |
|
| 78 return '{0:.2f}TB'.format(B/TB) |
|
| 79 |
|
| 80 def make_title(status): |
|
| 81 def add_info(info, new): |
|
| 82 if info: |
|
| 83 return "%s; %s" % (info, new) |
|
| 84 else: |
|
| 85 return new |
|
| 86 |
|
| 87 info=None |
|
| 88 this_need_reconstruct=None |
|
| 89 |
|
| 90 if not status.errors.ok(): |
|
| 91 info=add_info(info, str(status.errors)) |
|
| 92 |
|
| 93 if status.state==backup.State.SCHEDULED: |
|
| 94 # Operation scheduled |
|
| 95 when=status.when() |
|
| 96 now=time.time() |
|
| 97 |
|
| 98 if when<now: |
|
| 99 whenstr='overdue' |
|
| 100 info='' |
|
| 101 else: |
|
| 102 tnow=datetime.datetime.fromtimestamp(now) |
|
| 103 twhen=datetime.datetime.fromtimestamp(when) |
|
| 104 tendtoday=twhen.replace(hour=23,minute=59,second=59) |
|
| 105 tendtomorrow=tendtoday+datetime.timedelta(days=1) |
|
| 106 diff=datetime.timedelta(seconds=when-now) |
|
| 107 |
|
| 108 if twhen>tendtomorrow: |
|
| 109 whenday=datetime.date.fromtimestamp(when) |
|
| 110 whenstr='on %s' % twhen.date().isoformat() |
|
| 111 this_need_reconstruct=tendtoday+datetime.timedelta(seconds=1) |
|
| 112 elif diff.seconds>=12*60*60: # 12 hours |
|
| 113 whenstr='tomorrow' |
|
| 114 this_need_reconstruct=twhen-datetime.timedelta(hours=12) |
|
| 115 else: |
|
| 116 twhen=time.localtime(when) |
|
| 117 if twhen.tm_sec>30: |
|
| 118 # Round up minute display to avoid user confusion |
|
| 119 twhen=time.localtime(when+30) |
|
| 120 whenstr='at %02d:%02d' % (twhen.tm_hour, twhen.tm_min) |
|
| 121 |
|
| 122 this_info='' |
|
| 123 if 'reason' in status.detail: |
|
| 124 this_info=status.detail['reason'] + ' ' |
|
| 125 |
|
| 126 when_how_sched= "%s%s %s" % (this_info, status.operation, whenstr) |
|
| 127 |
|
| 128 info=add_info(info, when_how_sched) |
|
| 129 |
|
| 130 elif status.state==backup.State.QUEUED: |
|
| 131 info=add_info(info, "queued") |
|
| 132 elif status.state==backup.State.ACTIVE: |
|
| 133 # Operation running |
|
| 134 progress='' |
|
| 135 d=status.detail |
|
| 136 if 'progress_current' in d and 'progress_total' in d: |
|
| 137 progress=' %d%%' % (d['progress_current']/d['progress_total']) |
|
| 138 elif 'original_size' in d and 'deduplicated_size' in d: |
|
| 139 progress=' %s→%s' % (humanbytes(d['original_size']), |
|
| 140 humanbytes(d['deduplicated_size'])) |
|
| 141 |
|
| 142 howrunning = "running %s%s" % (status.operation, progress) |
|
| 143 |
|
| 144 info=add_info(info, howrunning) |
|
| 145 else: |
|
| 146 pass |
|
| 147 |
|
| 148 if info: |
|
| 149 title=status.name + ' (' + info + ')' |
|
| 150 else: |
|
| 151 title=status.name |
|
| 152 |
|
| 153 return title, (status.state, status.errors), this_need_reconstruct |
|
| 154 |
|
| 155 class BorgendTray(rumps.App): |
|
| 156 def __init__(self, backups): |
|
| 157 self.lock=Lock() |
|
| 158 self.backups=backups |
|
| 159 self.refresh_timer=None |
|
| 160 self.refresh_timer_time=None |
|
| 161 self.statuses=[None]*len(backups) |
|
| 162 |
|
| 163 for index in range(len(backups)): |
|
| 164 self.statuses[index]=backups[index].status() |
|
| 165 |
|
| 166 menu, title=self.build_menu_and_timer() |
|
| 167 |
|
| 168 super().__init__(title, menu=menu, quit_button=None) |
|
| 169 |
|
| 170 for index in range(len(backups)): |
|
| 171 # Python closures suck dog's balls; hence the _index=index hack |
|
| 172 # See also http://math.andrej.com/2009/04/09/pythons-lambda-is-broken/ |
|
| 173 cb=(lambda status, errors=None, _index=index: |
|
| 174 self.__status_callback(_index, status, errorlog=errors)) |
|
| 175 backups[index].set_status_update_callback(cb) |
|
| 176 |
|
| 177 dreamtime.add_callback(self, self.refresh_ui) |
|
| 178 |
|
| 179 def __rebuild_menu(self): |
|
| 180 menu=[] |
|
| 181 state=(backup.State.INACTIVE, backup.Errors.OK) |
|
| 182 need_reconstruct=None |
|
| 183 for index in range(len(self.backups)): |
|
| 184 b=self.backups[index] |
|
| 185 title, this_state, this_need_reconstruct=make_title(self.statuses[index]) |
|
| 186 # Python closures suck dog's balls... |
|
| 187 # first and the last program I write in Python until somebody |
|
| 188 # fixes this brain damage |
|
| 189 cbm=lambda sender, _b=b: self.__menu_select_backup(sender, _b) |
|
| 190 item=rumps.MenuItem(title, callback=cbm) |
|
| 191 if not this_state[1].ok(): |
|
| 192 item.state=-1 |
|
| 193 elif this_state[0]==backup.State.SCHEDULED or this_state[0]==backup.State.QUEUED: |
|
| 194 item.state=1 |
|
| 195 menu.append(item) |
|
| 196 state=combine_state(state, this_state) |
|
| 197 |
|
| 198 # Do we have to automatically update menu display? |
|
| 199 if not need_reconstruct: |
|
| 200 need_reconstruct=this_need_reconstruct |
|
| 201 elif this_need_reconstruct: |
|
| 202 need_reconstruct=min(need_reconstruct, this_need_reconstruct) |
|
| 203 |
|
| 204 menu_log=rumps.MenuItem("Show log", callback=lambda _: showlog()) |
|
| 205 menu.append(menu_log) |
|
| 206 |
|
| 207 if not settings['no_quit_menu_entry']: |
|
| 208 menu_quit=rumps.MenuItem("Quit...", callback=lambda _: self.quit()) |
|
| 209 menu.append(menu_quit) |
|
| 210 |
|
| 211 return menu, state, need_reconstruct |
|
| 212 |
|
| 213 def build_menu_and_timer(self): |
|
| 214 if self.refresh_timer: |
|
| 215 self.refresh_timer.cancel() |
|
| 216 self.refresh_timer=None |
|
| 217 self.refresh_timer_time=None |
|
| 218 logger.debug('Rebuilding menu') |
|
| 219 menu, state, need_reconstruct=self.__rebuild_menu() |
|
| 220 title=trayname(state) |
|
| 221 |
|
| 222 if need_reconstruct: |
|
| 223 when=time.mktime(need_reconstruct.timetuple()) |
|
| 224 delay=when-time.time() |
|
| 225 self.refresh_timer=Timer(delay, self.refresh_ui) |
|
| 226 self.refresh_timer_time=need_reconstruct |
|
| 227 self.refresh_timer.start() |
|
| 228 |
|
| 229 return menu, title |
|
| 230 |
|
| 231 def refresh_ui(self): |
|
| 232 with self.lock: |
|
| 233 menu, title=self.build_menu_and_timer() |
|
| 234 self.menu.clear() |
|
| 235 self.menu.update(menu) |
|
| 236 self.title=title |
|
| 237 |
|
| 238 def __status_callback(self, index, status, errorlog=None): |
|
| 239 logger.debug("Tray status callback") |
|
| 240 with self.lock: |
|
| 241 self.statuses[index]=status |
|
| 242 # Time the refresh if it has not been timed, or if the timer |
|
| 243 # is timing for the "long-term" (refresh_timer_time set) |
|
| 244 if not self.refresh_timer or self.refresh_timer_time: |
|
| 245 logger.debug("Timing refresh") |
|
| 246 self.refresh_timer=Timer(refresh_interval, self.refresh_ui) |
|
| 247 # refresh_timer_time is only set for "long-term timers" |
|
| 248 self.refresh_timer_time=None |
|
| 249 self.refresh_timer.start() |
|
| 250 |
|
| 251 if errorlog: |
|
| 252 if 'msgid' not in errorlog or not isinstance(errorlog['msgid'], str): |
|
| 253 msgid='UnknownError' |
|
| 254 else: |
|
| 255 msgid=errorlog['msgid'] |
|
| 256 |
|
| 257 logger.debug("Opening notification for error %s '%s'", |
|
| 258 msgid, errorlog['message']) |
|
| 259 |
|
| 260 notification_workaround(branding.appname_stylised, |
|
| 261 msgid, errorlog['message']) |
|
| 262 |
|
| 263 def quit(self): |
|
| 264 rumps.quit_application() |
|
| 265 |
|
| 266 def __menu_select_backup(self, sender, b): |
|
| 267 #sender.state=not sender.state |
|
| 268 logger.debug("Manually backup '%s'", b.name) |
|
| 269 try: |
|
| 270 b.create() |
|
| 271 except Exception as err: |
|
| 272 logger.exception("Failure to initialise backup") |
|
| 273 notification_workaround(branding.appname_stylised, |
|
| 274 err.__class__.__name__, str(err)) |
|
| 275 |
|
| 276 # |
|
| 277 # Log window |
|
| 278 # |
|
| 279 |
|
| 280 logwindow=[None] |
|
| 281 logwindow_lock=Lock() |
|
| 282 |
|
| 283 def showlog(): |
|
| 284 try: |
|
| 285 w=None |
|
| 286 with logwindow_lock: |
|
| 287 if not logwindow[0]: |
|
| 288 lines=borgend.fifolog.formatAll() |
|
| 289 msg="\n".join(lines[0:]) |
|
| 290 w=rumps.Window(title=borgend.appname_stylised+' log', |
|
| 291 default_text=msg, |
|
| 292 ok='Close', |
|
| 293 dimensions=(640,320)) |
|
| 294 logwindow[0]=w |
|
| 295 if w: |
|
| 296 try: |
|
| 297 w.run() |
|
| 298 finally: |
|
| 299 with logwindow_lock: |
|
| 300 logwindow[0]=None |
|
| 301 except Exception as err: |
|
| 302 logger.exception("Failed to display log") |
|
| 303 |
|
| 304 # |
|
| 305 # Notification click response => show log window |
|
| 306 # |
|
| 307 |
|
| 308 @rumps.notifications |
|
| 309 def notification_center(_): |
|
| 310 showlog() |
|
| 311 |
|