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 |
|