diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53c1daf --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Virtual environment +.venv/ +.env/ + +# Python cache and bytecode +__pycache__/ +*.pyc +*.pyo + +# Build artifacts +build/ +dist/ +*.egg-info/ + +# Dependency lock files +uv.lock +pipfile.lock + +# App-specific artifacts +__pycache__/ +*.spec + +# IDE specific +.vscode/ +.idea/ + +# macOS specific +.DS_Store \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8a0dad1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "route-manager" +version = "0.1.0" +description = "A simple GUI application to manage network routes on macOS." +readme = "README.md" +requires-python = ">=3.8" +license = { text = "MIT" } +authors = [ + { name = "Your Name", email = "you@example.com" }, +] +dependencies = [ + "netifaces>=0.11.0", +] + +[project.scripts] +route-manager = "wifi_macOS:main" # Assuming your main function is called 'main' in wifi_macOS.py + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +# This tells hatch to include the wifi_macOS.py file directly in the wheel. +# It assumes wifi_macOS.py is in the root of your project directory (Route_Manager). +packages = ["wifi_macOS.py"] \ No newline at end of file diff --git a/route_gui.py b/route_gui.py new file mode 100644 index 0000000..f8b4c8e --- /dev/null +++ b/route_gui.py @@ -0,0 +1,92 @@ +import tkinter as tk +from tkinter import messagebox, simpledialog + +class RouteManagerGUI: + def __init__(self, root, logic_handler): + self.root = root + self.logic = logic_handler + self.root.title("Route Manager") + + self.create_input_frame() + self.create_gateway_frame() + self.create_route_frame() + + self.root.grid_rowconfigure(2, weight=1) + self.root.grid_columnconfigure(0, weight=1) + self.route_frame.grid_rowconfigure(0, weight=1) + self.route_frame.grid_columnconfigure(0, weight=1) + + self.route_listbox.bind("", self.handle_delete_selected_route) + self.route_listbox.bind("", self.handle_delete_selected_route) + + def create_input_frame(self): + self.frame = tk.Frame(self.root) + self.frame.grid(row=0, column=0, padx=10, pady=10, sticky="nsew") + + tk.Label(self.frame, text="Enter URLs or IPs (separated by ;)").grid(row=0, column=0, padx=5) + self.ip_entry = tk.Entry(self.frame, width=50) + self.ip_entry.grid(row=0, column=1, padx=5) + + self.add_button = tk.Button(self.frame, text="Add", command=self.handle_add_route) + self.add_button.grid(row=0, column=2, padx=5) + + self.delete_button = tk.Button(self.frame, text="Delete", command=self.handle_delete_route) + self.delete_button.grid(row=0, column=3, padx=5) + + def create_gateway_frame(self): + self.gateway_frame = tk.Frame(self.root) + self.gateway_frame.grid(row=1, column=0, padx=10, pady=10, sticky="nsew") + + tk.Label(self.gateway_frame, text="en0 Gateway").grid(row=0, column=0, padx=5) + self.gateway_entry = tk.Entry(self.gateway_frame, width=50) + self.gateway_entry.insert(0, self.logic.en0_gateway) + self.gateway_entry.config(state='readonly') + self.gateway_entry.grid(row=0, column=1, padx=5) + + def create_route_frame(self): + self.route_frame = tk.Frame(self.root) + self.route_frame.grid(row=2, column=0, padx=10, pady=10, sticky="nsew") + + self.route_listbox = tk.Listbox(self.route_frame, width=80, height=20) + self.route_listbox.grid(row=0, column=0, sticky="nsew") + + self.scrollbar = tk.Scrollbar(self.route_frame, command=self.route_listbox.yview) + self.scrollbar.grid(row=0, column=1, sticky="ns") + + self.route_listbox.config(yscrollcommand=self.scrollbar.set) + + def handle_add_route(self): + input_value = self.ip_entry.get() + self.logic.add_route(input_value) + + def handle_delete_route(self): + input_value = self.ip_entry.get() + self.logic.delete_route(input_value) + + def handle_delete_selected_route(self, event): + index = self.route_listbox.nearest(event.y) + selected_route = self.route_listbox.get(index) + fields = selected_route.split() + if len(fields) > 0: + ip_address = fields[0] + self.logic.delete_route(ip_address) + + def update_route_display(self, routes_output): + self.route_listbox.delete(0, tk.END) + for line in routes_output: + self.route_listbox.insert(tk.END, line) + + def update_gateway_display(self, gateway): + self.gateway_entry.config(state='normal') + self.gateway_entry.delete(0, tk.END) + self.gateway_entry.insert(0, gateway) + self.gateway_entry.config(state='readonly') + + def show_error(self, title, message): + messagebox.showerror(title, message) + + def show_info(self, title, message): + messagebox.showinfo(title, message) + + def ask_sudo_password(self): + return simpledialog.askstring("Password", "Enter sudo password:", show='*') \ No newline at end of file diff --git a/wifi_macOS.py b/wifi_macOS.py index e14219b..0edf657 100644 --- a/wifi_macOS.py +++ b/wifi_macOS.py @@ -1,93 +1,27 @@ import tkinter as tk -from tkinter import messagebox, simpledialog +from tkinter import messagebox # Keep for logic class, though GUI class also has it import subprocess import threading import socket import netifaces as ni import json import os +from route_gui import RouteManagerGUI -class RouteManagerApp: - def __init__(self, root): - self.root = root - self.root.title("Route Manager") # Set the window title - - # Define the path to the JSON file +class RouteLogic: + def __init__(self, gui_updater): + self.gui = gui_updater self.json_file = os.path.expanduser("~/routes.json") - - # Get the gateway of the en0 interface self.en0_gateway = self.get_gateway_by_interface('en0') - - # Store the sudo password self.sudo_password = None - - # Create the input frame and buttons - self.create_input_frame() - - # Create a read-only field to display the en0 gateway - self.create_gateway_frame() - - # Create the route display interface - self.create_route_frame() - - # Configure weights to allow the route listbox and scrollbar to resize with the window - self.root.grid_rowconfigure(2, weight=1) - self.root.grid_columnconfigure(0, weight=1) - self.route_frame.grid_rowconfigure(0, weight=1) - self.route_frame.grid_columnconfigure(0, weight=1) - - # Load routes from the JSON file self.routes = self.load_routes() - # If the gateway has changed, re-add all routes if self.routes and self.routes.get("gateway") != self.en0_gateway: self.readd_routes() - - # Initialize the display of current routes - self.update_route_display() - - # Bind double-click events - self.route_listbox.bind("", self.delete_selected_route) - self.route_listbox.bind("", self.delete_selected_route) - - def create_input_frame(self): - self.frame = tk.Frame(self.root) - self.frame.grid(row=0, column=0, padx=10, pady=10, sticky="nsew") - - tk.Label(self.frame, text="Enter URLs or IPs (separated by ;)").grid(row=0, column=0, padx=5) - self.ip_entry = tk.Entry(self.frame, width=50) # Entry for URLs or IPs - self.ip_entry.grid(row=0, column=1, padx=5) - - self.add_button = tk.Button(self.frame, text="Add", command=self.add_route) # Add button - self.add_button.grid(row=0, column=2, padx=5) - - self.delete_button = tk.Button(self.frame, text="Delete", command=self.delete_route) # Delete button - self.delete_button.grid(row=0, column=3, padx=5) - - def create_gateway_frame(self): - self.gateway_frame = tk.Frame(self.root) - self.gateway_frame.grid(row=1, column=0, padx=10, pady=10, sticky="nsew") - - tk.Label(self.gateway_frame, text="en0 Gateway").grid(row=0, column=0, padx=5) - self.gateway_entry = tk.Entry(self.gateway_frame, width=50) # Display en0 gateway - self.gateway_entry.insert(0, self.en0_gateway) # Populate with en0 gateway - self.gateway_entry.config(state='readonly') # Set to read-only - self.gateway_entry.grid(row=0, column=1, padx=5) - - def create_route_frame(self): - self.route_frame = tk.Frame(self.root) - self.route_frame.grid(row=2, column=0, padx=10, pady=10, sticky="nsew") - - self.route_listbox = tk.Listbox(self.route_frame, width=80, height=20) # Route list - self.route_listbox.grid(row=0, column=0, sticky="nsew") - - self.scrollbar = tk.Scrollbar(self.route_frame, command=self.route_listbox.yview) # Scrollbar - self.scrollbar.grid(row=0, column=1, sticky="ns") - - self.route_listbox.config(yscrollcommand=self.scrollbar.set) + + self.update_route_display_on_gui() def get_gateway_by_interface(self, interface_name): - # Get the gateway for a specific interface gateways = ni.gateways() for interface, gateway_info in gateways.items(): if isinstance(gateway_info, list): @@ -97,7 +31,6 @@ def get_gateway_by_interface(self, interface_name): return None def load_routes(self): - # Load routes from the JSON file if os.path.exists(self.json_file): with open(self.json_file, "r") as file: data = json.load(file) @@ -108,49 +41,43 @@ def load_routes(self): return {"gateway": None, "routes": []} def save_routes(self): - # Save routes to the JSON file with open(self.json_file, "w") as file: json.dump(self.routes, file, indent=4) def readd_routes(self): - # Re-add all routes based on the new gateway old_gateway = self.routes.get("gateway") if old_gateway: for route in self.routes["routes"]: try: self.execute_sudo_command(f"route delete {route}") except subprocess.CalledProcessError as e: - messagebox.showerror("Error", f"Failed to delete old route: {route} ({e})") + self.gui.show_error("Error", f"Failed to delete old route: {route} ({e})") self.routes["gateway"] = self.en0_gateway for route in self.routes["routes"]: try: self.execute_sudo_command(f"route -n add {route} {self.en0_gateway}") except subprocess.CalledProcessError as e: - messagebox.showerror("Error", f"Failed to add new route: {route} ({e})") + self.gui.show_error("Error", f"Failed to add new route: {route} ({e})") self.save_routes() - self.update_route_display() + self.update_route_display_on_gui() - def update_route_display(self): - # Update the route display - self.route_listbox.delete(0, tk.END) # Clear current display + def update_route_display_on_gui(self): + routes_output = [] try: - # Get the current routing table output = subprocess.check_output(["netstat", "-rn"]).decode("utf-8") for line in output.splitlines(): - # Skip default route if "default" in line: continue - # Check if the gateway is the en0 gateway fields = line.split() if len(fields) > 1 and fields[1] == self.en0_gateway: - self.route_listbox.insert(tk.END, line) + routes_output.append(line) + self.gui.update_route_display(routes_output) except subprocess.CalledProcessError as e: - messagebox.showerror("Error", f"Failed to retrieve routing table: {e}") + self.gui.show_error("Error", f"Failed to retrieve routing table: {e}") def validate_sudo_password(self): - # Validate the sudo password if not self.sudo_password: - self.sudo_password = simpledialog.askstring("Password", "Enter sudo password:", show='*') + self.sudo_password = self.gui.ask_sudo_password() if not self.sudo_password: raise subprocess.CalledProcessError(1, "Password input canceled") @@ -164,14 +91,12 @@ def validate_sudo_password(self): stdout, stderr = process.communicate() if process.returncode != 0: - messagebox.showerror("Error", f"Password validation failed: {stderr}") + self.gui.show_error("Error", f"Password validation failed: {stderr}") self.sudo_password = None raise subprocess.CalledProcessError(process.returncode, "Password validation failed") def execute_sudo_command(self, command): - # Execute a command that requires sudo privileges self.validate_sudo_password() - process = subprocess.Popen( f'echo {self.sudo_password} | sudo -S {command}', shell=True, @@ -182,21 +107,16 @@ def execute_sudo_command(self, command): stdout, stderr = process.communicate() if process.returncode != 0: - messagebox.showerror("Error", f"Command execution failed: {stderr}") + self.gui.show_error("Error", f"Command execution failed: {stderr}") raise subprocess.CalledProcessError(process.returncode, command, output=stderr) - return stdout - def add_route(self): - # Get the user input for URLs or IPs - input_value = self.ip_entry.get() + def add_route(self, input_value): if not input_value: - messagebox.showerror("Error", "Input cannot be empty") # Show error if input is empty + self.gui.show_error("Error", "Input cannot be empty") return - # Split the input URLs or IPs urls = input_value.split(";") - gateway = self.en0_gateway success_count = 0 @@ -204,96 +124,98 @@ def add_route(self): url = url.strip() if not url: continue - try: - # Attempt to resolve the input to an IP address ip_address = socket.gethostbyname(url) except socket.gaierror as e: - messagebox.showerror("Error", f"Failed to resolve input: {url} ({e})") + self.gui.show_error("Error", f"Failed to resolve input: {url} ({e})") continue - try: - # Use sudo to execute the route command to add the route self.execute_sudo_command(f"route -n add {ip_address}/32 {gateway}") - if ip_address not in self.routes["routes"]: + if f"{ip_address}/32" not in self.routes["routes"]: self.routes["routes"].append(f"{ip_address}/32") success_count += 1 except subprocess.CalledProcessError as e: - messagebox.showerror("Error", f"Failed to add route: {url} ({e})") + self.gui.show_error("Error", f"Failed to add route: {url} ({e})") if success_count > 0: self.routes["gateway"] = self.en0_gateway self.save_routes() - messagebox.showinfo("Success", f"Successfully added {success_count} routes") - self.update_route_display() # Update route display - - def delete_route(self, ip_address=None): - # Get the user input for IP addresses - if ip_address is None: - input_value = self.ip_entry.get() - if not input_value: - messagebox.showerror("Error", "Input cannot be empty") # Show error if input is empty - return + self.gui.show_info("Success", f"Successfully added {success_count} routes") + self.update_route_display_on_gui() - # Split the input IP addresses - ips = input_value.split(";") - else: - ips = [ip_address] + def delete_route(self, input_value_or_ip): + if not input_value_or_ip: + self.gui.show_error("Error", "Input cannot be empty") + return + ips_to_delete = [] + if ';' in input_value_or_ip: # Check if it's a ;-separated list from entry + ips_to_delete = [ip.strip() for ip in input_value_or_ip.split(";") if ip.strip()] + else: # Assume it's a single IP (e.g., from double-click) + ips_to_delete = [input_value_or_ip.strip()] + success_count = 0 - - for ip in ips: - ip = ip.strip() + for ip in ips_to_delete: if not ip: continue - try: - # Use sudo to execute the route command to delete the route self.execute_sudo_command(f"route delete {ip}") - if ip in self.routes["routes"]: - self.routes["routes"].remove(ip) + # Ensure we remove the correct format (e.g. with /32 if stored that way) + route_to_remove = ip + if not '/' in ip and any(r.startswith(ip + '/') for r in self.routes["routes"]): + # find the full route string if only ip is provided + for r_full in self.routes["routes"]: + if r_full.startswith(ip + '/'): + route_to_remove = r_full + break + if route_to_remove in self.routes["routes"]: + self.routes["routes"].remove(route_to_remove) success_count += 1 except subprocess.CalledProcessError as e: - messagebox.showerror("Error", f"Failed to delete route: {ip} ({e})") + self.gui.show_error("Error", f"Failed to delete route: {ip} ({e})") if success_count > 0: self.save_routes() - messagebox.showinfo("Success", f"Successfully deleted {success_count} routes") - self.update_route_display() # Update route display - - def delete_selected_route(self, event): - # Get the selected route entry from the double-click - index = self.route_listbox.nearest(event.y) - selected_route = self.route_listbox.get(index) - fields = selected_route.split() - if len(fields) > 0: - ip_address = fields[0] - self.delete_route(ip_address) + self.gui.show_info("Success", f"Successfully deleted {success_count} routes") + self.update_route_display_on_gui() def monitor_wifi_changes(self): - # Run the tail command to monitor the system log in real-time process = subprocess.Popen(['tail', '-f', '/var/log/wifi.log'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - try: while True: line = process.stdout.readline() if not line: break - - # Check for WiFi connection change events - if 'Gateway' in line: - self.en0_gateway = self.get_gateway_by_interface('en0') - self.gateway_entry.config(state='normal') # Set to editable - self.gateway_entry.delete(0, tk.END) - self.gateway_entry.insert(0, self.en0_gateway) # Populate with en0 gateway - self.gateway_entry.config(state='readonly') # Set to read-only - self.readd_routes() - self.update_route_display() + if 'Gateway' in line: # Simplified check, might need refinement + new_gateway = self.get_gateway_by_interface('en0') + if new_gateway != self.en0_gateway: + self.en0_gateway = new_gateway + self.gui.update_gateway_display(self.en0_gateway) + self.readd_routes() + self.update_route_display_on_gui() except KeyboardInterrupt: - messagebox.showerror("Error") + self.gui.show_error("Monitor Error", "WiFi monitor interrupted.") + except Exception as e: + self.gui.show_error("Monitor Error", f"WiFi monitor error: {e}") if __name__ == "__main__": - root = tk.Tk() # Create the main window - app = RouteManagerApp(root) # Initialize the application - threading.Thread(target=app.monitor_wifi_changes, args=(), daemon=True).start() - root.mainloop() # Start the event loop and display the window \ No newline at end of file + root = tk.Tk() + # Create a dummy gui_updater for RouteLogic initialization + # The actual GUI will be created and then its methods passed or the GUI instance itself + class DummyGUIUpdater: + def update_route_display(self, routes_output): pass + def update_gateway_display(self, gateway): pass + def show_error(self, title, message): print(f"Error: {title} - {message}") + def show_info(self, title, message): print(f"Info: {title} - {message}") + def ask_sudo_password(self): return "test_password" # Placeholder + + app_logic = RouteLogic(DummyGUIUpdater()) # Initialize logic first to get gateway + app_gui = RouteManagerGUI(root, app_logic) # Pass logic to GUI + app_logic.gui = app_gui # Now link the actual GUI to the logic class + + # Initial display updates + app_gui.update_gateway_display(app_logic.en0_gateway) + app_logic.update_route_display_on_gui() + + threading.Thread(target=app_logic.monitor_wifi_changes, daemon=True).start() + root.mainloop() \ No newline at end of file