#!/usr/bin/python3 # File: automount-trayicon.py # startdate: 2020-09-24 13:17 # References: # https://gitlab.com/bgstack15/logout-manager/-/blob/master/src/usr/bin/logout-manager-trayicon # https://github.com/gapan/xdgmenumaker/blob/master/src/xdgmenumaker # https://github.com/seb-m/pyinotify/blob/master/python2/examples/stats_threaded.py # https://stackoverflow.com/questions/28279363/python-way-to-get-mounted-filesystems/28279434#28279434 # timer https://python-gtk-3-tutorial.readthedocs.io/en/latest/spinner.html?highlight=timer#id1 # vim: ts=3 sw=3 sts=3 # Improve: # move out config to separate file (read main .conf, but in shell format) # add all headers # document # Dependencies: # autofs, root running the included stackrpms-automount import gi, os, fnmatch, sys, pyinotify, time, subprocess gi.require_version("Gtk","3.0") from gi.repository import Gtk, Gdk, GLib import xdg.DesktopEntry as dentry import xdg.Exceptions as exc showmount = 1 # show the "MOUNTED" value for each drive skip_sd_without_partitions = 1 # skip sdb sdc, etc. # NOT IMPLEMENTED YET. only_update_on_menuitem = 0 # if 1, then disables the mouseover poll. Prevents showmount=1 from working. AUTOMOUNT_BASEDIR="/media" AUTOMOUNT_BROWSEDIR="/browse" # FUNCTIONS class App: ''' A class to keep individual app details in. ''' def __init__(self, name, icon, command, path): self.name = name self.icon = icon self.command = command self.path = path def __repr__(self): return repr((self.name, self.icon, self.command, self.path)) class MenuEntry: ''' A class for each menu entry. Includes the class category and app details from the App class. ''' def __init__(self, category, app): self.category = category self.app = app def __repr__(self): return repr((self.category, self.app.name, self.app.icon, self.app.command, self.app.path)) class Identity(pyinotify.ProcessEvent): activity_count = 0 def process_default(self, event): print(self,"Does nothing with the event number",self.activity_count) self.activity_count += 1 #print(event) def remove_command_keys(command, desktopfile, icon): # replace the %i (icon key) if it's there. This is what freedesktop has to # say about it: "The Icon key of the desktop entry expanded as two # arguments, first --icon and then the value of the Icon key. Should not # expand to any arguments if the Icon key is empty or missing." if icon: command = command.replace('"%i"', '--icon {}'.format(icon)) command = command.replace("'%i'", '--icon {}'.format(icon)) command = command.replace('%i', '--icon {}'.format(icon)) # some KDE apps have this "-caption %c" in a few variations. %c is "The # translated name of the application as listed in the appropriate Name key # in the desktop entry" according to freedesktop. All apps launch without a # problem without it as far as I can tell, so it's better to remove it than # have to deal with extra sets of nested quotes which behave differently in # each WM. This is not 100% failure-proof. There might be other variations # of this out there, but we can't account for every single one. If someone # finds one another one, I can always add it later. command = command.replace('-caption "%c"', '') command = command.replace("-caption '%c'", '') command = command.replace('-caption %c', '') # replace the %k key. This is what freedesktop says about it: "The # location of the desktop file as either a URI (if for example gotten from # the vfolder system) or a local filename or empty if no location is # known." command = command.replace('"%k"', desktopfile) command = command.replace("'%k'", desktopfile) command = command.replace('%k', desktopfile) # removing any remaining keys from the command. That can potentially remove # any other trailing options after the keys, command = command.partition('%')[0] return command def icon_strip(icon): # strip the directory and extension from the icon name icon = os.path.basename(icon) main, ext = os.path.splitext(icon) ext = ext.lower() if ext == '.png' or ext == '.svg' or ext == '.svgz' or ext == '.xpm': return main return icon def get_entry_info(desktopfile, ico_paths=True): # customized from gapan/xdgmenumaker de = dentry.DesktopEntry(filename=desktopfile) name = de.getName().encode('utf-8') if True: icon = de.getIcon() # full resolution of path is not required in this GTK program. #if ico_paths: # icon = icon_full_path(icon) #else: # icon = icon_strip(icon) else: icon = None command = de.getExec() command = remove_command_keys(command, desktopfile, icon) terminal = de.getTerminal() if terminal: command = '{term} -e {cmd}'.format(term=terminal_app, cmd=command) path = de.getPath() if not path: path = None categories = de.getCategories() category = "Removable" # hardcoded xdg "category" for automount-trayicon app = App(name, icon, command, path) mentry = MenuEntry(category, app) return mentry def desktopfilelist(params): # Ripped entirely from gapan/xdgmenumaker dirs = [] for i in params: print("Checking dir",i) i = i.rstrip('/') if i not in dirs: dirs.append(i) filelist = [] df_temp = [] for d in dirs: xdgdir = '{}/'.format(d) # xdg spec is to use "{}/applications" as this string, so use an applications subdir. if os.path.isdir(xdgdir): for root, dirnames, filenames in os.walk(xdgdir): for i in fnmatch.filter(filenames, '*.desktop'): # for duplicate .desktop files that exist in more # than one locations, only keep the first occurence. # That one should have precedence anyway (e.g. # ~/.local/share/applications has precedence over # /usr/share/applications if i not in df_temp: df_temp.append(i) filelist.append(os.path.join(root, i)) return filelist class MainIcon(Gtk.StatusIcon): def __init__(self): Gtk.StatusIcon.__init__(self) self.set_from_icon_name("media-removable") self.traymenu = Gtk.Menu() self.connect("button-press-event", self.on_button_press_event) self.connect("popup-menu", self.context_menu) self.reestablish_menu() if not only_update_on_menuitem: self.connect("query-tooltip", self.mouseover) # need these anyway, for when the icon is hidden. self.wm1 = pyinotify.WatchManager() self.s1 = pyinotify.Stats() self.identity=Identity(self.s1) # provides self.identity.activity_counter which increments for every change to the watched directory self.activity_count=0 # the cached value, for comparison. Update the menu when this changes! self.notifier1 = pyinotify.ThreadedNotifier(self.wm1, default_proc_fun=self.identity) self.notifier1.start() self.wm1.add_watch(AUTOMOUNT_BASEDIR, pyinotify.IN_CREATE | pyinotify.IN_DELETE, rec=True, auto_add=True) def mouseover(self, second_self, x, y, some_bool, tooltip): #print("Mouseover happened at",str(x)+",",y,tooltip, some_bool) need_showmount=0 if self.identity.activity_count != self.activity_count: # then we need to update menu print("Mouseover, and files have changed!") self.activity_count = self.identity.activity_count need_showmount=1 if need_showmount or showmount: self.reestablish_menu() def reestablish_menu(self,widget = None, silent=False): if not silent: print("Reestablishing the menu") try: if self.menuitems: del self.menuitems except: pass try: for i in self.traymenu.get_children(): self.traymenu.remove(i) except: pass self.menuitems = [] for entry in get_desktop_entries([AUTOMOUNT_BASEDIR]): if not silent: print('{} {} {} {}'.format(entry.app.name.decode("utf-8"),entry.app.icon,entry.app.path,entry.app.command)) self.add_menuitem(str(entry.app.name.decode("utf-8")),entry.app.path,entry.app.icon,self.execute,entry.app.command) self.add_separator_to_menu() if only_update_on_menuitem: self.add_menuitem("Update menu","",None,self.reestablish_menu,"re establish") # If you want a menu option for this self.add_menuitem("Hide until next disk change","","",self.hide,"hide") self.add_menuitem("Exit automount-trayicon","","system-logout",self.exit,"exit") self.menuitem_count = len(self.menuitems) - 2 self.set_tooltip_text(str(self.menuitem_count)+" mount point"+("s" if self.menuitem_count > 1 else "")) def execute(self, widget): x=0 ; y=-1 for n in widget.get_parent().get_children(): x+=1 if widget == n: y=x-1 break if y > -1: print(y,self.menuitems[y]) # use subprocess to open in background so failure to mount dialogs do not block the program subprocess.Popen(self.menuitems[y].split()) #os.system(self.menuitems[y]) else: print("Some kind of error?! How did this get called without a menu entry.") #print(repr(self.s1)) def add_separator_to_menu(self): i=Gtk.SeparatorMenuItem.new() i.set_visible(True) self.traymenu.append(i) def add_menuitem(self,label_str,label_paren_str,icon_str,function_func,action_str): self.menuitems.append(action_str) full_label_str=label_str mounted_str="" # collection = [line.split()[1] for line in open("/etc/mtab") if line.split()[1].startswith('/browse') and line.split()[2] != "autofs"] if label_paren_str is not None and label_paren_str != "": if showmount: if label_paren_str in [line.split()[1] for line in open("/etc/mtab") if line.split()[1].startswith(AUTOMOUNT_BROWSEDIR) and line.split()[2] != "autofs"]: label_paren_str = "MOUNTED " + label_paren_str full_label_str += " (" + label_paren_str + ")" i = Gtk.ImageMenuItem.new_with_mnemonic(full_label_str) j = Gtk.Image.new_from_icon_name(icon_str,32) j.show() i.set_image(j) i.set_always_show_image(True) i.show() i.connect("activate", function_func) self.traymenu.append(i) def on_button_press_event(self, b_unknown, event: Gdk.EventButton): # for some reason the single click functionality prevents the double click functionality if Gdk.EventType._2BUTTON_PRESS == event.type: # not achievable if we are popping up the menu in the single click. print("Double click") else: print("Single click") self.traymenu.popup(None, None, self.position_menu, self, event.button, Gtk.get_current_event_time()) def context_menu(self, widget, event_button, event_time): self.traymenu.popup(None, None, self.position_menu, self, event_button, Gtk.get_current_event_time()) def hide(self, widget = None): print("Please hide self!") self.set_visible(False) self.start_timer() def start_timer(self): """ Start the timer """ self.counter = 1000 # milliseconds, but hardly a precise timer. Do not use this for real calculations self.timeout_id = GLib.timeout_add(1,self.on_timeout, None) def stop_timer(self, reason_str): if self.timeout_id: GLib.source_remove(self.timeout_id) self.timeout_id = None #print("Timer was stopped!") self.check_reenable_now() def check_reenable_now(self): print("Need to check if anything has changed.") print(self.identity.activity_count,self.activity_count) if self.identity.activity_count != self.activity_count: self.set_visible(True) self.activity_count = self.identity.activity_count self.reestablish_menu() else: print("No changes...") self.hide() def on_timeout(self, *args, **kwargs): """ A timeout function """ self.counter -= 1 if self.counter <= 0: self.stop_timer("Reached time out") return False return True def exit(self, widget): self.notifier1.stop() quit() def __quit__(self): self.notifier1.stop() Gtk.StatusIcon.__quit__(self) # Normally call get_desktop_entries with (["/media"]) def get_desktop_entries(dir_array): entries = [] for desktopfile in desktopfilelist(dir_array): try: entry = get_entry_info(desktopfile, True) if entry is not None: #print(entry) entries.append(entry) except exc.ParsingError as Ex: print(Ex) pass return entries # MAIN icon = MainIcon() Gtk.main()