Need to time menu updates using Apple's frameworks to avoid segfaults.

Sun, 05 Dec 2021 00:42:01 +0200

author
Tuomo Valkonen <tuomov@iki.fi>
date
Sun, 05 Dec 2021 00:42:01 +0200
changeset 133
ec8014a2ee7a
parent 132
8fe3cf6487f8
child 134
a7aa8ca7b3d0

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/ui.py file | annotate | diff | comparison | revisions
--- a/borgend/ui.py	Fri Oct 30 14:09:39 2020 -0500
+++ b/borgend/ui.py	Sun Dec 05 00:42:01 2021 +0200
@@ -10,7 +10,9 @@
 import logging
 import objc
 import math
-from threading import Lock, Timer
+from threading import Lock
+from Foundation import (NSDate, NSTimer, NSRunLoop, NSDefaultRunLoopMode)
+from AppKit import (NSEventTrackingRunLoopMode)
 
 from . import backup
 from . import dreamtime
@@ -48,8 +50,8 @@
 def combine_state(a, b):
     return (max(a[0], b[0]), max(a[1], b[1]))
 
-# Refresh the menu at most once a second to reduce flicker
-refresh_interval=1.0
+# Refresh the menu at most twice a second
+refresh_interval=0.5
 
 # Workaround to rumps brokenness;
 # see https://github.com/jaredks/rumps/issues/59
@@ -185,20 +187,38 @@
 
     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=None
-        self.refresh_timer_time=None
+        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()
 
-        menu, title=self.build_menu_and_timer()
+        itemlist, title=self.build_menu_and_timer()
 
-        super().__init__(title, menu=menu, quit_button=None)
+        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
@@ -209,8 +229,12 @@
 
         dreamtime.add_callback(self, self.__sleepwake_callback)
 
-    def __rebuild_menu(self):
-        menu=[]
+    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
@@ -222,12 +246,23 @@
             # 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)
-            item=rumps.MenuItem(title, callback=cbm)
+            itemstate=0
             if not this_state[1].ok():
-                item.state=-1
+                itemstate=-1
             elif this_state[0]==backup.State.SCHEDULED or this_state[0]==backup.State.QUEUED:
-                item.state=1
-            menu.append(item)
+                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)
@@ -238,68 +273,76 @@
             elif this_refresh_time:
                 refresh_time=min(refresh_time, this_refresh_time)
 
-        menu_log=rumps.MenuItem("Show log", callback=lambda _: showlog())
-        menu.append(menu_log)
-
+        index = len(self.backups)
         if all_paused:
             pausename='Resume all'
         else:
             pausename="Pause all"
-        menu_pause=rumps.MenuItem(pausename, callback=lambda _: self.pause_resume_all())
-        menu.append(menu_pause)
+        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 settings['no_quit_menu_entry']:
-            menu_quit=rumps.MenuItem("Quit...", callback=lambda _: self.quit())
-            menu.append(menu_quit)
-
-        return menu, state, refresh_time
+        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):
-        if self.refresh_timer:
-            self.refresh_timer.cancel()
-            self.refresh_timer=None
-            self.refresh_timer_time=None
+    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
 
-        logger.debug('Rebuilding menu')
-        menu, state, refresh_time=self.__rebuild_menu()
-        title=trayname(state)
-
-        if refresh_time:
+        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=when-time.time()
-            if delay>0:
-                logger.debug('Timing menu refresh in %s seconds' % delay)
-                self.refresh_timer=Timer(delay, self.refresh_ui)
-                self.refresh_timer_time=refresh_time
-                self.refresh_timer.start()
+            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 menu, title
+        return itemlist, title
 
     # Callbacks -- exception-protected to get any indications of errors
 
     @protect_noreturn
-    def refresh_ui(self):
+    def __refresh_callback(self, timer):
         with self.lock:
-            menu, title=self.build_menu_and_timer()
-            self.menu.clear()
-            self.menu.update(menu)
-            self.title=title
-
+            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 not self.refresh_timer or self.refresh_timer_time:
-                # logger.debug("Timing refresh")
-                self.refresh_timer=Timer(refresh_interval, self.refresh_ui)
-                # refresh_timer_time is only set for "long-term timers"
-                self.refresh_timer_time=None
-                self.refresh_timer.start()
+            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):

mercurial