ui.py

changeset 80
a409242121d5
parent 79
b075b3db3044
child 81
7bcd715f19e3
equal deleted inserted replaced
79:b075b3db3044 80:a409242121d5
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

mercurial