summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rwxr-xr-xgmm511
-rwxr-xr-xgmm-tk203
-rw-r--r--gmm_lib.py336
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__
diff --git a/gmm b/gmm
deleted file mode 100755
index d0ae750..0000000
--- a/gmm
+++ /dev/null
@@ -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()
diff --git a/gmm-tk b/gmm-tk
new file mode 100755
index 0000000..d1472df
--- /dev/null
+++ b/gmm-tk
@@ -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
bgstack15