diff options
-rw-r--r-- | .gitignore | 2 | ||||
-rwxr-xr-x | gmm | 511 | ||||
-rwxr-xr-x | gmm-tk | 203 | ||||
-rw-r--r-- | gmm_lib.py | 336 |
4 files changed, 541 insertions, 511 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d513b9e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.*.swp +__pycache__ @@ -1,511 +0,0 @@ -#!/usr/bin/env python3 -# vim: set et ts=4 sts=4 sw=4: -# Startdate: 2024-11-18-2 13:58 -# Title: Graphical Mount Manager -# Purpose: Easily mount iso files and easily manage these mounted files and mount points, basically like acetoneiso -# Dependencies: -# req-devuan: python3, python3-psutil, python3-tkstackrpms -# References: -# https://stackoverflow.com/questions/23662280/how-to-log-the-contents-of-a-configparser/50362738#50362738 -# srb_lib/srb_tk.py -# https://coderslegacy.com/python/list-of-tkinter-widgets/ -# https://stackoverflow.com/questions/46331408/limiting-python-filedialog-to-a-specific-filetype/46339932#46339932 -# https://stackoverflow.com/questions/30614279/tkinter-treeview-get-selected-item-values -# https://www.reddit.com/r/learnpython/comments/hcn8cc/cant_bind_double_click_to_treeview_with_grid/ -# for drag and drop, I tried a few things that failed: -# https://sourceforge.net/projects/tkdnd/ failed because it does not support python or in between applications -# https://github.com/python/cpython/blob/main/Lib/tkinter/dnd.py says it is within the same application -# https://stackoverflow.com/questions/44887576/how-can-i-create-a-drag-and-drop-interface within but it does not work with a file dragged in. -# Improve: -# get tkstackrpms in a venv, so I can try pip install tkinterdnd2., https://www.delftstack.com/howto/python-tkinter/tkinter-drag-and-drop/#download-and-setup-the-essential-packages-for-drag-and-drop-in-tkinter -# need .desktop file that takes application/x-iso9660-image, that calls this with --gui - -import argparse, sys, os, psutil, subprocess, json, configparser, tkinter as tk, tkinter.simpledialog, tkinter.filedialog -import tkstackrpms as stk -import tkinter.ttk as ttk - -# LOW-LEVEL values -appname = "gmm" -appversion = "0.0.1" -conffile = os.path.join(os.getenv("HOME"),".config",appname,"config") - -ABOUT_TEXT = """ -gmm "Graphical Mount Manager" -(C) 2024 bgstack15 -SPDX-License-Identifier: GPL-3.0-only -""" - -# LOW-LEVEL FUNCTIONS -def ferror(*args, **kwargs): - print(*args, file=sys.stderr, **kwargs) - -def debuglev(_numbertocheck): - # if _numbertocheck <= debuglevel then return truthy - _debuglev = False - try: - if int(_numbertocheck) <= int(debuglevel): - _debuglev = True - except Exception as e: - pass - return _debuglev - -# APP FUNCTIONS -def list_mounts(): - """ - Return a python list of FILE,MOUNT pairs - """ - # get list of devices - mounts = [ - { - "device": i.device, - "mountpoint": i.mountpoint - } for i in psutil.disk_partitions(False) if i.fstype in ["iso9660"] - ] - # use losetup --json, - losetup_p = subprocess.Popen( - ["/usr/sbin/losetup","--json"], stdout=subprocess.PIPE - ) - losetup_o, _ = losetup_p.communicate() - try: - losetup_j = json.loads(losetup_o) - except Exception as e: - ferror(f"Got error {e} when trying to read losetup output") - losetup_j = {} - if losetup_j: - losetup_j = losetup_j["loopdevices"] - for i in mounts: - #for j in losetup_j["loopdevices"]: - # if i.device == j.name: - try: - i["source"] = [j["back-file"] for j in losetup_j if j["name"] == i["device"]][0] - except Exception as e: - ferror(f"While looking for {i['device']} in {losetup_j}, got error {e}") - for i in mounts: - if "source" in i: - i.pop("device") - # Now we have a list of - # [{'mountpoint': '/mnt/foo', 'source': '/mnt/public/CDROMs/Games/Logical Journey of the Zoombinis.iso'}] - return mounts - -def mount_iso_to_path(isofile, mount_point): - """ - Mount the isofile to mount_point using sudo. No protections of already-mounted or something-else-mounted. - """ - mount_p = subprocess.Popen( - ["sudo","mount","-v",isofile,mount_point], stdout=subprocess.PIPE - ) - mount_o, mount_e = mount_p.communicate() - if debuglev(5): - if mount_e: - ferror(mount_e) - -def mount_iso_to_default(isofile, config = None): - """ - Use the gmm config to determine where to mount this. - """ - if not config: - raise Exception(f"Fatal: Cannot determine where to mount. Check config file, probably {conffile}.") - md = config[appname]["mounts_dir"] - # determine next available number. 0-indexed. If you want to make this 1-indexed, set x to 0. - x = -1 - safe = False - # safety valve: do not try mount more than 20 mount points. - while (not safe) or x < 20: - x = x + 1 - testpath = os.path.join(md,str(x)) - if not os.path.exists(testpath): - safe = True - break - else: - if os.path.ismount(testpath): - if iso_is_mounted_to_path(isofile, testpath): - if debuglev(2): - ferror(f"INFO: Found {isofile} mounted at {testpath} already. Skipping...") - # short-circuit - return True - # if a mountpoint but is not the requested isofile - if debuglev(8): - ferror(f"DEBUG: Found different mountpoint at {testpath}, continuing to look...") - # skip this number - continue - elif os.path.isdir(testpath): - if os.listdir(testpath): - # not empty so skip it - ferror(f"WARNING: non-mountpoint {testpath} is not empty, continuing to look...") - continue - else: - # empty directory, this is what we want - safe = True - break - else: - # so not a mountpoint and not a dir, so skip - ferror(f"WARNING: non-directory {testpath}, continuing to look...") - continue - # end while - # notably, because of all the continues and breaks, we cannot increment x at bottom of this loop. - if debuglev(8): - ferror(f"DEBUG: after loop, testpath is {testpath}") - if safe: - if debuglev(1): - print(f"INFO: will mount {isofile} to {testpath}.") - try: - os.mkdir(testpath) - # FUTUREIMPROVEMENT: might need to catch specific exceptions and pass them, like dir-already-exists. - except: - pass - mount_iso_to_path(isofile, testpath) - -def unmount_iso_to_path(isofile, mount_point): - """ - Unmount the isofile to mount_point using sudo. - This exploits that the "isofile" could be the actual iso file that is mounted, or the mount point. The umount command can either to unmount something. - """ - params = ["sudo","umount","-v",isofile,mount_point] - params = [i for i in params if i] - if debuglev(1): - ferror(f"Running: {params}") - mount_p = subprocess.Popen( - params, stdout=subprocess.PIPE - ) - mount_o, mount_e = mount_p.communicate() - if debuglev(5): - if mount_e: - ferror(mount_e) - # and now delete the empty directory - if not mount_point: - mount_point = isofile - if os.path.isdir(mount_point) and (not os.listdir(mount_point)): - try: - os.rmdir(mount_point) - except Exception as e: - if debuglev(5): - ferror(f"INFO: while trying to remove now-empty dir {mount_point}, got error {e}, continuing...") - -def get_iso_mounted_to_path(mount_point, mounts = None): - """ - Return the filename of what is mounted to mount_point. - """ - if not os.path.ismount(mount_point): - return False - if not mounts: - mounts = list_mounts() - for i in mounts: - if i["mountpoint"] == mount_point: - # short-circuit because there can be only one thing mounted to a mount_point - return i["source"] - return None - -def iso_is_mounted_to_path(iso_file, mount_point, mounts = None): - """ - Return True if the named iso_file is mounted to mount_point. - """ - if not os.path.ismount(mount_point): - return False - if not mounts: - mounts = list_mounts() - for i in mounts: - if i["mountpoint"] == mount_point and i["source"] == iso_file: - # short-circuit - return True - return False - -# PARSE ARGUMENTS -parser = argparse.ArgumentParser(description="graphical mount manager",prog=appname,epilog=""" The value-add of this application is to use a configured directory for -mountpoints, for easy mounting, e.g., from a graphical file manager. Run with an -iso filename, and it will mount to ~/mnt/0, for example.""") -parser.add_argument("-d","--debug", nargs='?', default=0, type=int, choices=range(0,11), help="Set debug level.") -parser.add_argument("-V","--version", action="version", version="%(prog)s " + appversion) -parser.add_argument("-g","--gui", action=argparse.BooleanOptionalAction, default=None, help="Show gui, even if also given any other actions.") -parser.add_argument("-l","--list", action="store_true", help="List mounted isos and paths and then exit.") -parser.add_argument("-o","--output", default="tsv", choices=("tsv","raw","json"), help="Change output format for --list.") -parser.add_argument("-u","--unmount","--umount", action="store_true", help="If used with one or more paths, umount them instead of mounting.") -parser.add_argument("-a","--all", action="store_true", help="Used only with --unmount. Unmount all mounted paths in default-configured dir.") -parser.add_argument("paths", nargs="*",help="Positional parameters: ISO_FILE, MOUNT_PATH") -#parser.add_argument("-f","--force", action="store_true", help="Force deploy all confs.") -parser.add_argument("-c","--conf","--config", action="store", default=conffile, help="Use this config file.") -#parser.add_argument("-r","--dry","--dryrun","--dry-run", action="store_true", help="Do not execute. Useful when debugging.") -args = parser.parse_args() -if args.debug is None: - debuglevel = 5 -elif args.debug: - debuglevel = args.debug -if debuglev(1): - ferror("debug level", debuglevel) - ferror(args) -paths = args.paths -umount = args.unmount -show_gui = True - -# Parse config -config = configparser.ConfigParser() -if args.conf: - conffile = args.conf -def load_config(configfile): - if debuglev(1): - ferror(f"Loading config file {configfile}") - config.read(configfile) - try: - #config["gmm"]["mounts_dir"] = os.path.expanduser(config["gmm"]["mounts_dir"]) - config.set(appname,"mounts_dir", os.path.expanduser(config[appname]["mounts_dir"].strip('"'))) - except: - pass - if debuglev(9): - ferror({section: dict(config[section]) for section in config.sections()}) -load_config(conffile) - -def save_config(configfile): - # un-expand the tilde - md = config[appname]["mounts_dir"] - # by adding the trailing slash, we make sure it is not just the HOME, but that would be an extreme situation. - if md.startswith(os.path.expanduser("~")+"/"): - md = md.replace(os.path.expanduser("~"),"~") - config.set(appname,"mounts_dir",md) - with open(configfile,"w") as cf: - config.write(cf) - -# GRAPHICAL APP -# Settings window -def NewWindow(textvar, func1, ok_button_func, mounts=None): - window = tk.Toplevel() - #window.geometry("250x250") - window.minsize(100,50) - window.title("Settings") - #newlabel = tk.Label(window, text="Settings" - #newlabel.pack() - tk.Label(window, text="Mounts directory").grid(row=0,column=0) - ent_mounts_dir = stk.Entry(window,textvariable=textvar,func=func1) - ent_mounts_dir.grid(row=0,column=1) - tk.Button(window,text="OK",underline=0,command=ok_button_func).grid(row=1,column=1) - # WORKHERE: explain why it is disabled to the user - if mounts: - ent_mounts_dir.configure(state="disabled") - return window - -# want mount iso... file dialog, that has two mimetype choices: *.iso, or * -# a combo box/list box thing of mounted isos, mount point -# a config file/dialog for choosing default mounts dir, which is managed by this app. E.g., /mnt/iso so the first disc is /mnt/iso/1 or something. Or ~/mnt/iso. Something like that. -# MAIN WINDOW -class App(tk.Frame): - def __init__(self, master): - super().__init__(master) - # variables - self.mounts = list_mounts() - self.mounts_dir = tk.StringVar() - self.master.title("Graphical Mount Manager") - self.master.minsize(550,200) - imgicon = stk.get_scaled_icon("dvd_unmount",24,"default","","apps") - self.master.tk.call("wm","iconphoto",self.master._w,imgicon) - menu = tk.Menu(self.master) - menu_file = tk.Menu(menu,tearoff=0) - menu_file.add_command(label="Mount iso...", command=self.func_mount_iso_dialog, underline=0) - menu_file.add_command(label="Settings...", command=self.func_open_settings, underline=0) - menu_file.add_separator() - menu_file.add_command(label="Exit", command=self.func_exit, underline=1) - menu.add_cascade(label="File",menu=menu_file,underline=0) - menu_help = tk.Menu(menu,tearoff=0) - menu_help.add_command(label="About", command=self.func_about, underline=0) - menu.add_cascade(label="Help",menu=menu_help,underline=0) - self.master.config(menu=menu) - self.grid() # use this instead of pack() - self.background_color = self.master.cget("bg") - self.mlframe = tk.Frame(self.master) - self.mlframe.grid(row=0,column=0,columnspan=3,sticky="ew") - # treeview, which requires ttk - self.ml = ttk.Treeview(self.mlframe, columns=("source","mountpoint"),show="headings") - #help(self.ml) - self.ml.grid(column=0,row=0,columnspan=3,sticky="ew") - self.ml.heading("source",text="Source") - self.ml.heading("mountpoint",text="Mount point") - self.ml.column("source",width=400) - self.ml.bind("<Double-1>", self.func_double_click_entry) - # unmount button - self.unmount_btn = tk.Button(self.master,text="Unmount",command=self.func_unmount_current_selection,underline=0).grid(column=0,row=1) - self.master.bind("<Alt-u>",self.func_unmount_current_selection) - # initial load - self.refresh_form("initial") - -# GRAPHICAL FUNCTIONS - def func_about(self): - """ Display about dialog. """ - tk.messagebox.Message(title="About",message=ABOUT_TEXT,icon="info").show() - - def func_exit(self): - # in case we need to manually do stuff - # otherwise command=self.client_exit would have sufficed. - self.master.quit() - - def func_open_settings(self): - self.settingsWindow = NewWindow(self.mounts_dir,self.save_settings, self.func_close_settings, self.mounts) - - def func_close_settings(self): - if debuglev(8): - ferror(f"Closing settings window") - try: - self.settingsWindow.destroy() - except Exception as e: - ferror(f"DEBUG: when trying to hide settings window, got {e}") - self.refresh_form() - - def get_current_selection(self, attribute="mountpoint"): - """ - Get the current path, or other attribute, from the treeview - """ - value = None - # There might be nothing selected, so return nothing - values = self.ml.item(self.ml.focus())["values"] - if debuglev(9): - ferror(f"DEBUG: got values {values}") - try: - if attribute == "mountpoint": - value = values[1] - elif attribute == "source": - value = values[0] - except IndexError: - return None - return value - - def func_unmount_current_selection(self, o1 = None): - if debuglev(9): - ferror(f"func_unmount_current_selection: {o1}") - # get current selection - path = self.get_current_selection() - if path: - unmount_iso_to_path(path,"") - self.refresh_form() - elif debuglev(4): - ferror(f"INFO: Nothing selected to unmount, continuing...") - - def refresh_form(self,secondObj = None): - if debuglev(9): - ferror(f"DEBUG: refresh_form got secondObj {secondObj}, class {type(secondObj)}") - # compare all config settings to see if they are different - load_config(conffile) # this populates object "config" - self.mounts_dir.set(config[appname]["mounts_dir"]) - # reload mounts - self.mounts = list_mounts() - self.ml.delete(*self.ml.get_children()) - for i in self.mounts: - self.ml.insert("",tk.END, values=(i["source"],i["mountpoint"])) - - def func_double_click_entry(self, o1 = None, o2 = None, o3 = None): - """ - Open the mounted directory when double-clicked. - """ - if debuglev(9): - ferror(f"DEBUG: double-click {o1},{o2},{o3}") - # It is possible to have nothing selected, so just throw a warning - path = self.get_current_selection() - if path: - if debuglev(1): - ferror(f"Running xdg-open {path}") - subprocess.Popen(["xdg-open",path]) - else: - if debuglev(4): - ferror(f"INFO: No item selected to open, continuing...") - pass - - def save_settings(self,secondObj = None): - if debuglev(1): - ferror(f"DEBUG: saving config file from gui") - config.set(appname,"mounts_dir",self.mounts_dir.get()) - save_config(conffile) - self.refresh_form() - - def func_mount_iso_dialog(self): - """ - Display a file chooser dialog to mount. - """ - filename = tk.filedialog.askopenfilename( - filetypes = [ - ("Disc image","*.iso"), - ("All files","*") - ] - ) - if filename: - ferror(f"Got {filename}") - mount_iso_to_default(filename,config) - self.refresh_form() - -# WORKHERE: support drag-and-drop onto the treeview? -# MAIN CLI -if "__main__" == __name__: - mounts = list_mounts() - # default behavior is to show the gui, unless --no-gui, or given some paths to mount, or --list - if (args.gui == False) or (args.list) or (paths) or ((not paths) and args.all and umount): - show_gui = False - if args.gui == True: - show_gui = True - if args.list: - if args.output == "json": - print(json.dumps(mounts)) - elif args.output == "tsv": - if mounts: - print("source\tpath") - for i in mounts: - print(f"{i['source']}\t{i['mountpoint']}") - else: - print(mounts) - if paths: - # calculate if paths.len = 2, then check if paths[0] is a file and paths[1] is a dir or underneath - if len(paths) == 2: - left = paths[0] - right = paths[1] - if os.path.isfile(left): - if os.path.isdir(right) or (not os.path.exists(right)): - if os.path.ismount(right): - if debuglev(8): - ferror(f"DEBUG: Investigating current mount {right} to see if it is same as {left}") - if iso_is_mounted_to_path(left, right, mounts): - if umount: - unmount_iso_to_path(left, right) - else: - if debuglev(1): - print(f"Already mounted!") - else: - ferror(f"ERROR: {get_iso_mounted_to_path(right,mounts)} is already mounted to {right}.") - # FUTUREIMPROVEMENT: If we want to implement --force to umount old, then mount this left, do it here. - else: - if umount: - if debuglev(2): - print(f"INFO: not a mount point, so nothing to do.") - else: - if debuglev(1): - print(f"INFO: will mount {left} to {right}.") - mount_iso_to_path(left, right) - elif os.path.isfile(right): - for i in paths: - mount_iso_to_default(i,config) - else: - ferror(f"ERROR: second path {right} is not a dir or file. Choose a different spot") - else: - ferror(f"ERROR: first path is not a file: {left}. Skipping...") - elif len(paths) == 1: - # main default method; the path to the iso to mount - isofile = paths[0] - # not ideal, but figure out that the user wanted "--list" - if isofile == "list" and not os.path.isfile(isofile): - ferror(f"WARNING: please use --list to list mounts. Continuing...") - print(mounts) - sys.exit(0) - if umount: - unmount_iso_to_path(isofile,"") - else: - mount_iso_to_default(isofile,config) - else: - # more than one path, so treat each as a file to mount - for isofile in paths: - if umount: - unmount_iso_to_path(isofile,"") - else: - mount_iso_to_default(isofile,config) - else: - # no paths, so check if --all and --umount - if umount and args.all: - for i in mounts: - unmount_iso_to_path(i["source"],i["mountpoint"]) - - # MAIN GRAPICAL APP - if show_gui: - root = tk.Tk() - gmm_tk = App(root) - gmm_tk.mainloop() @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +# vim: set et ts=4 sts=4 sw=4: +# Startdate: 2024-11-18-2 13:58 +# Title: Graphical Mount Manager +# Purpose: Easily mount iso files and easily manage these mounted files and mount points, basically like acetoneiso +# Dependencies: +# req-devuan: python3, python3-tkstackrpms +# References: +# https://stackoverflow.com/questions/23662280/how-to-log-the-contents-of-a-configparser/50362738#50362738 +# srb_lib/srb_tk.py +# https://coderslegacy.com/python/list-of-tkinter-widgets/ +# https://stackoverflow.com/questions/46331408/limiting-python-filedialog-to-a-specific-filetype/46339932#46339932 +# https://stackoverflow.com/questions/30614279/tkinter-treeview-get-selected-item-values +# https://www.reddit.com/r/learnpython/comments/hcn8cc/cant_bind_double_click_to_treeview_with_grid/ +# for drag and drop, I tried a few things that failed: +# https://sourceforge.net/projects/tkdnd/ failed because it does not support python or in between applications +# https://github.com/python/cpython/blob/main/Lib/tkinter/dnd.py says it is within the same application +# https://stackoverflow.com/questions/44887576/how-can-i-create-a-drag-and-drop-interface within but it does not work with a file dragged in. +# Improve: +# get tkstackrpms in a venv, so I can try pip install tkinterdnd2., https://www.delftstack.com/howto/python-tkinter/tkinter-drag-and-drop/#download-and-setup-the-essential-packages-for-drag-and-drop-in-tkinter +# need .desktop file that takes application/x-iso9660-image, that calls this with --gui + +import sys, subprocess, json, configparser, tkinter as tk, tkinter.simpledialog, tkinter.filedialog +import tkstackrpms as stk +import tkinter.ttk as ttk + +sys.path.append(".") +import gmm_lib as gmm +from gmm_lib import debuglev, ferror, appname + +# GRAPHICAL APP +# Settings window +def NewWindow(textvar, func1, ok_button_func, mounts=None): + window = tk.Toplevel() + #window.geometry("250x250") + window.minsize(100,50) + window.title("Settings") + #newlabel = tk.Label(window, text="Settings" + #newlabel.pack() + tk.Label(window, text="Mounts directory").grid(row=0,column=0) + ent_mounts_dir = stk.Entry(window,textvariable=textvar,func=func1) + ent_mounts_dir.grid(row=0,column=1) + tk.Button(window,text="OK",underline=0,command=ok_button_func).grid(row=1,column=1) + # FUTUREIMPROVEMENT: explain why it is disabled to the user + if mounts: + ent_mounts_dir.configure(state="disabled") + return window + +# want mount iso... file dialog, that has two mimetype choices: *.iso, or * +# a combo box/list box thing of mounted isos, mount point +# a config file/dialog for choosing default mounts dir, which is managed by this app. E.g., /mnt/iso so the first disc is /mnt/iso/1 or something. Or ~/mnt/iso. Something like that. +# MAIN WINDOW +class TkApp(tk.Frame): + def __init__(self, master, gmmapp): + super().__init__(master) + # variables + self.gmmapp = gmmapp + self.mounts_dir = tk.StringVar() + self.master.title("Graphical Mount Manager") + self.master.minsize(550,200) + imgicon = stk.get_scaled_icon("dvd_unmount",24,"default","","apps") + self.master.tk.call("wm","iconphoto",self.master._w,imgicon) + menu = tk.Menu(self.master) + menu_file = tk.Menu(menu,tearoff=0) + menu_file.add_command(label="Mount iso...", command=self.func_mount_iso_dialog, underline=0) + menu_file.add_command(label="Settings...", command=self.func_open_settings, underline=0) + menu_file.add_separator() + menu_file.add_command(label="Exit", command=self.func_exit, underline=1) + menu.add_cascade(label="File",menu=menu_file,underline=0) + menu_help = tk.Menu(menu,tearoff=0) + menu_help.add_command(label="About", command=self.func_about, underline=0) + menu.add_cascade(label="Help",menu=menu_help,underline=0) + self.master.config(menu=menu) + self.grid() # use this instead of pack() + self.background_color = self.master.cget("bg") + self.mlframe = tk.Frame(self.master) + self.mlframe.grid(row=0,column=0,columnspan=3,sticky="ew") + # treeview, which requires ttk + self.ml = ttk.Treeview(self.mlframe, columns=("source","mountpoint"),show="headings") + #help(self.ml) + self.ml.grid(column=0,row=0,columnspan=3,sticky="ew") + self.ml.heading("source",text="Source") + self.ml.heading("mountpoint",text="Mount point") + self.ml.column("source",width=400) + self.ml.bind("<Double-1>", self.func_double_click_entry) + # unmount button + self.unmount_btn = tk.Button(self.master,text="Unmount",command=self.func_unmount_current_selection,underline=0).grid(column=0,row=1) + self.master.bind("<Alt-u>",self.func_unmount_current_selection) + # initial load + self.refresh_form("initial") + +# GRAPHICAL FUNCTIONS + def func_about(self): + """ Display about dialog. """ + tk.messagebox.Message(title="About",message=ABOUT_TEXT,icon="info").show() + + def func_exit(self): + # in case we need to manually do stuff + # otherwise command=self.client_exit would have sufficed. + self.master.quit() + + def func_open_settings(self): + self.settingsWindow = NewWindow(self.mounts_dir,self.save_settings, self.func_close_settings, self.gmmapp.mounts) + + def func_close_settings(self): + if debuglev(8): + ferror(f"Closing settings window") + try: + self.settingsWindow.destroy() + except Exception as e: + ferror(f"DEBUG: when trying to hide settings window, got {e}") + self.refresh_form() + + def get_current_selection(self, attribute="mountpoint"): + """ + Get the current path, or other attribute, from the treeview + """ + value = None + # There might be nothing selected, so return nothing + values = self.ml.item(self.ml.focus())["values"] + if debuglev(9): + ferror(f"DEBUG: got values {values}") + try: + if attribute == "mountpoint": + value = values[1] + elif attribute == "source": + value = values[0] + except IndexError: + return None + return value + + def func_unmount_current_selection(self, o1 = None): + if debuglev(9): + ferror(f"func_unmount_current_selection: {o1}") + # get current selection + path = self.get_current_selection() + if path: + self.gmmapp.unmount_iso_to_path(path,"") + self.refresh_form() + elif debuglev(4): + ferror(f"INFO: Nothing selected to unmount, continuing...") + + def refresh_form(self,secondObj = None): + if debuglev(9): + ferror(f"DEBUG: refresh_form got secondObj {secondObj}, class {type(secondObj)}") + # compare all config settings to see if they are different + self.gmmapp.load_config(self.gmmapp.conffile) # this populates object "self.gmmapp.config" + self.mounts_dir.set(self.gmmapp.config[appname]["mounts_dir"]) + # reload mounts + self.gmmapp.mounts = self.gmmapp.list_mounts() + self.ml.delete(*self.ml.get_children()) + for i in self.gmmapp.mounts: + self.ml.insert("",tk.END, values=(i["source"],i["mountpoint"])) + + def func_double_click_entry(self, o1 = None, o2 = None, o3 = None): + """ + Open the mounted directory when double-clicked. + """ + if debuglev(9): + ferror(f"DEBUG: double-click {o1},{o2},{o3}") + # It is possible to have nothing selected, so just throw a warning + path = self.get_current_selection() + if path: + if debuglev(1): + ferror(f"Running xdg-open {path}") + subprocess.Popen(["xdg-open",path]) + else: + if debuglev(4): + ferror(f"INFO: No item selected to open, continuing...") + pass + + def save_settings(self,secondObj = None): + if debuglev(1): + ferror(f"DEBUG: saving config file from gui") + self.gmmapp.config.set(appname,"mounts_dir",self.mounts_dir.get()) + save_config(self.gmmapp.conffile) + self.refresh_form() + + def func_mount_iso_dialog(self): + """ + Display a file chooser dialog to mount. + """ + filename = tk.filedialog.askopenfilename( + filetypes = [ + ("Disc image","*.iso"), + ("All files","*") + ] + ) + if filename: + ferror(f"Got {filename}") + self.gmmapp.mount_iso_to_default(filename,self.gmmapp.config) + self.refresh_form() + +if "__main__" == __name__: + # MAIN GRAPICAL APP + App = gmm.Gmm(gmm.args) + print(f"before cli_main show_gui: {App.show_gui}") + App.cli_main() + print(f"after cli_main show_gui: {App.show_gui}") + if App.show_gui: + root = tk.Tk() + gmm_tk = TkApp(root, App) + gmm_tk.mainloop() diff --git a/gmm_lib.py b/gmm_lib.py new file mode 100644 index 0000000..b037b1a --- /dev/null +++ b/gmm_lib.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python3 +# vim: set et ts=4 sts=4 sw=4: +# Startdate: 2024-11-20-4 12:50 +# Title: Library for Graphical Mount Manager +# Dependencies: +# req-devuan: python3, python3-psutil +import argparse, sys, os, psutil, subprocess, json, configparser + +# LOW-LEVEL values +appname = "gmm" +appversion = "0.0.1" +conffile = os.path.join(os.getenv("HOME"),".config",appname,"config") + +ABOUT_TEXT = """ +gmm "Graphical Mount Manager" +(C) 2024 bgstack15 +SPDX-License-Identifier: GPL-3.0-only +""" + +# LOW-LEVEL FUNCTIONS +def ferror(*args, **kwargs): + print(*args, file=sys.stderr, **kwargs) + +def debuglev(_numbertocheck): + # if _numbertocheck <= debuglevel then return truthy + _debuglev = False + try: + if int(_numbertocheck) <= int(debuglevel): + _debuglev = True + except Exception as e: + pass + return _debuglev + + # APP FUNCTIONS + +class Gmm(): + + def __init__(self,args=None): + # Parse config + self.conffile = conffile + try: + if args.conf: + self.conffile = args.conf + except: + pass + self.mounts = self.list_mounts() + self.args = args + self.config = configparser.ConfigParser() + self.load_config(self.conffile) + # will be set to False if --no-gui, or some action is given + self.show_gui = True + + def list_mounts(self): + """ + Return a python list of FILE,MOUNT pairs + """ + # get list of devices + mounts = [ + { + "device": i.device, + "mountpoint": i.mountpoint + } for i in psutil.disk_partitions(False) if i.fstype in ["iso9660"] + ] + # use losetup --json, + losetup_p = subprocess.Popen( + ["/usr/sbin/losetup","--json"], stdout=subprocess.PIPE + ) + losetup_o, _ = losetup_p.communicate() + try: + losetup_j = json.loads(losetup_o) + except Exception as e: + ferror(f"Got error {e} when trying to read losetup output") + losetup_j = {} + if losetup_j: + losetup_j = losetup_j["loopdevices"] + for i in mounts: + #for j in losetup_j["loopdevices"]: + # if i.device == j.name: + try: + i["source"] = [j["back-file"] for j in losetup_j if j["name"] == i["device"]][0] + except Exception as e: + ferror(f"While looking for {i['device']} in {losetup_j}, got error {e}") + for i in mounts: + if "source" in i: + i.pop("device") + # Now we have a list of + # [{'mountpoint': '/mnt/foo', 'source': '/mnt/public/CDROMs/Games/Logical Journey of the Zoombinis.iso'}] + return mounts + + def mount_iso_to_path(self, isofile, mount_point): + """ + Mount the isofile to mount_point using sudo. No protections of already-mounted or something-else-mounted. + """ + mount_p = subprocess.Popen( + ["sudo","mount","-v",isofile,mount_point], stdout=subprocess.PIPE + ) + mount_o, mount_e = mount_p.communicate() + if debuglev(5): + if mount_e: + ferror(mount_e) + + def mount_iso_to_default(self,isofile): + """ + Use the gmm config to determine where to mount this. + """ + md = self.config[appname]["mounts_dir"] + # determine next available number. 0-indexed. If you want to make this 1-indexed, set x to 0. + x = -1 + safe = False + # safety valve: do not try mount more than 20 mount points. + while (not safe) or x < 20: + x = x + 1 + testpath = os.path.join(md,str(x)) + if not os.path.exists(testpath): + safe = True + break + else: + if os.path.ismount(testpath): + if self.iso_is_mounted_to_path(isofile, testpath): + if debuglev(2): + ferror(f"INFO: Found {isofile} mounted at {testpath} already. Skipping...") + # short-circuit + return True + # if a mountpoint but is not the requested isofile + if debuglev(8): + ferror(f"DEBUG: Found different mountpoint at {testpath}, continuing to look...") + # skip this number + continue + elif os.path.isdir(testpath): + if os.listdir(testpath): + # not empty so skip it + ferror(f"WARNING: non-mountpoint {testpath} is not empty, continuing to look...") + continue + else: + # empty directory, this is what we want + safe = True + break + else: + # so not a mountpoint and not a dir, so skip + ferror(f"WARNING: non-directory {testpath}, continuing to look...") + continue + # end while + # notably, because of all the continues and breaks, we cannot increment x at bottom of this loop. + if debuglev(8): + ferror(f"DEBUG: after loop, testpath is {testpath}") + if safe: + if debuglev(1): + print(f"INFO: will mount {isofile} to {testpath}.") + try: + os.mkdir(testpath) + # FUTUREIMPROVEMENT: might need to catch specific exceptions and pass them, like dir-already-exists. + except: + pass + self.mount_iso_to_path(isofile, testpath) + + def unmount_iso_to_path(self, isofile, mount_point): + """ + Unmount the isofile to mount_point using sudo. + This exploits that the "isofile" could be the actual iso file that is mounted, or the mount point. The umount command can either to unmount something. + """ + params = ["sudo","umount","-v",isofile,mount_point] + params = [i for i in params if i] + if debuglev(1): + ferror(f"Running: {params}") + mount_p = subprocess.Popen( + params, stdout=subprocess.PIPE + ) + mount_o, mount_e = mount_p.communicate() + if debuglev(5): + if mount_e: + ferror(mount_e) + # and now delete the empty directory + if not mount_point: + mount_point = isofile + if os.path.isdir(mount_point) and (not os.listdir(mount_point)): + try: + os.rmdir(mount_point) + except Exception as e: + if debuglev(5): + ferror(f"INFO: while trying to remove now-empty dir {mount_point}, got error {e}, continuing...") + + def get_iso_mounted_to_path(self, mount_point): + """ + Return the filename of what is mounted to mount_point. + """ + if not os.path.ismount(mount_point): + return False + if not self.mounts: + self.mounts = self.list_mounts() + for i in self.mounts: + if i["mountpoint"] == mount_point: + # short-circuit because there can be only one thing mounted to a mount_point + return i["source"] + return None + + def iso_is_mounted_to_path(self, iso_file, mount_point): + """ + Return True if the named iso_file is mounted to mount_point. + """ + if not os.path.ismount(mount_point): + return False + if not self.mounts: + self.mounts = self.list_mounts() + for i in self.mounts: + if i["mountpoint"] == mount_point and i["source"] == iso_file: + # short-circuit + return True + return False + + def load_config(self, configfile): + if debuglev(1): + ferror(f"Loading config file {configfile}") + self.config.read(configfile) + try: + #config["gmm"]["mounts_dir"] = os.path.expanduser(config["gmm"]["mounts_dir"]) + self.config.set(appname,"mounts_dir", os.path.expanduser(self.config[appname]["mounts_dir"].strip('"'))) + except: + pass + if debuglev(9): + ferror({section: dict(self.config[section]) for section in self.config.sections()}) + + def save_config(self, configfile = None): + if not configfile: + configfile = self.conffile + # un-expand the tilde + md = self.config[appname]["mounts_dir"] + # by adding the trailing slash, we make sure it is not just the HOME, but that would be an extreme situation. + if md.startswith(os.path.expanduser("~")+"/"): + md = md.replace(os.path.expanduser("~"),"~") + self.config.set(appname,"mounts_dir",md) + with open(configfile,"w") as cf: + config.write(cf) + + def cli_main(self): + # default behavior is to show the gui, unless --no-gui, or given some paths to mount, or --list + args = self.args + paths = args.paths + umount = args.unmount + if (args.list) or (paths) or ((not paths) and args.all and umount): + self.show_gui = False + if args.gui == False or args.gui == True: + ferror(f"Setting show_gui to {args.gui}") + self.show_gui = args.gui + if args.list: + if args.output == "json": + print(json.dumps(self.mounts)) + elif args.output == "tsv": + if self.mounts: + print("source\tpath") + for i in self.mounts: + print(f"{i['source']}\t{i['mountpoint']}") + else: + print(self.mounts) + if paths: + # calculate if paths.len = 2, then check if paths[0] is a file and paths[1] is a dir or underneath + if len(paths) == 2: + left = paths[0] + right = paths[1] + if os.path.isfile(left): + if os.path.isdir(right) or (not os.path.exists(right)): + if os.path.ismount(right): + if debuglev(8): + ferror(f"DEBUG: Investigating current mount {right} to see if it is same as {left}") + if iso_is_mounted_to_path(left, right, self.mounts): + if umount: + self.unmount_iso_to_path(left, right) + else: + if debuglev(1): + print(f"Already mounted!") + else: + ferror(f"ERROR: {get_iso_mounted_to_path(right,self.mounts)} is already mounted to {right}.") + # FUTUREIMPROVEMENT: If we want to implement --force to umount old, then mount this left, do it here. + else: + if umount: + if debuglev(2): + print(f"INFO: not a mount point, so nothing to do.") + else: + if debuglev(1): + print(f"INFO: will mount {left} to {right}.") + mount_iso_to_path(left, right) + elif os.path.isfile(right): + for i in paths: + self.mount_iso_to_default(i) + else: + ferror(f"ERROR: second path {right} is not a dir or file. Choose a different spot") + else: + ferror(f"ERROR: first path is not a file: {left}. Skipping...") + elif len(paths) == 1: + # main default method; the path to the iso to mount + isofile = paths[0] + # not ideal, but figure out that the user wanted "--list" + if isofile == "list" and not os.path.isfile(isofile): + ferror(f"WARNING: please use --list to list mounts. Continuing...") + print(self.mounts) + sys.exit(0) + if umount: + self.unmount_iso_to_path(isofile,"") + else: + mount_iso_to_default(isofile) + else: + # more than one path, so treat each as a file to mount + for isofile in paths: + if umount: + self.unmount_iso_to_path(isofile,"") + else: + self.mount_iso_to_default(isofile) + else: + # no paths, so check if --all and --umount + if umount and args.all: + for i in self.mounts: + self.unmount_iso_to_path(i["source"],i["mountpoint"]) + +# PARSE ARGUMENTS +parser = argparse.ArgumentParser(description="graphical mount manager",prog=appname,epilog=""" The value-add of this application is to use a configured directory for +mountpoints, for easy mounting, e.g., from a graphical file manager. Run with an +iso filename, and it will mount to ~/mnt/0, for example.""") +parser.add_argument("-d","--debug", nargs='?', default=0, type=int, choices=range(0,11), help="Set debug level.") +parser.add_argument("-V","--version", action="version", version="%(prog)s " + appversion) +parser.add_argument("-g","--gui", action=argparse.BooleanOptionalAction, default=None, help="Show gui, even if also given any other actions.") +parser.add_argument("-l","--list", action="store_true", help="List mounted isos and paths and then exit.") +parser.add_argument("-o","--output", default="tsv", choices=("tsv","raw","json"), help="Change output format for --list.") +parser.add_argument("-u","--unmount","--umount", action="store_true", help="If used with one or more paths, umount them instead of mounting.") +parser.add_argument("-a","--all", action="store_true", help="Used only with --unmount. Unmount all mounted paths in default-configured dir.") +parser.add_argument("paths", nargs="*",help="Positional parameters: ISO_FILE, MOUNT_PATH") +#parser.add_argument("-f","--force", action="store_true", help="Force deploy all confs.") +parser.add_argument("-c","--conf","--config", action="store", default=conffile, help="Use this config file.") +#parser.add_argument("-r","--dry","--dryrun","--dry-run", action="store_true", help="Do not execute. Useful when debugging.") +args = parser.parse_args() +if args.debug is None: + debuglevel = 5 +elif args.debug: + debuglevel = args.debug +if debuglev(1): + ferror("debug level", debuglevel) + ferror(args) +umount = args.unmount |