diff --git a/FadCrypt.py b/FadCrypt.py index 312797d..74367a4 100644 --- a/FadCrypt.py +++ b/FadCrypt.py @@ -44,6 +44,7 @@ # Import shared core modules from core.config_manager import ConfigManager +from core.application_manager import ApplicationManager # Embedded configuration and state data @@ -89,6 +90,9 @@ def __init__(self, master): app_locker=None, # Will be set after app_locker is created get_fadcrypt_folder_func=lambda: self.app_locker.get_fadcrypt_folder() ) + + # Initialize shared ApplicationManager + self.app_manager = None # Will be initialized in create_widgets after tabs are created self.set_app_icon() # Set the custom app icon self.create_widgets() @@ -124,15 +128,16 @@ def open_add_application_dialog(self): # Center the dialog on the screen screen_width = self.add_dialog.winfo_screenwidth() screen_height = self.add_dialog.winfo_screenheight() - dialog_width = 400 # Adjust width as needed - dialog_height = 500 # Adjust height as needed + dialog_width = 420 # Increased width slightly + dialog_height = 580 # Increased height to accommodate all content # position_x = (screen_width // 2) - (dialog_width // 2) position_x = 50 # Position the dialog on the left edge of the screen position_y = (screen_height // 2) - (dialog_height // 2) self.add_dialog.geometry(f"{dialog_width}x{dialog_height}+{position_x}+{position_y}") - # Prevent resizing - self.add_dialog.resizable(False, False) + # Allow resizing so user can expand if needed + self.add_dialog.resizable(True, True) + self.add_dialog.minsize(400, 550) # Set minimum size # Ensure the dialog is focused self.add_dialog.attributes('-topmost', True) @@ -181,12 +186,38 @@ def update_text_position(): browse_button = ttk.Button(manual_frame, text="Browse", command=self.browse_for_file, style="navy.TButton") browse_button.pack(pady=5) + # Buttons frame + buttons_frame = ttk.Frame(self.add_dialog) + buttons_frame.pack(pady=10) + + # Scan Apps Button (new feature!) + scan_button = ttk.Button( + buttons_frame, + text="šŸ” Scan for Apps", + command=self.scan_for_apps, + width=15, + style="navy.TButton" + ) + scan_button.pack(side=tk.LEFT, padx=5) + # Save Button - save_button = ttk.Button(self.add_dialog, text="Save", command=self.save_application, width=11, style="green.TButton") - save_button.pack(pady=10) + save_button = ttk.Button( + buttons_frame, + text="šŸ’¾ Save", + command=self.save_application, + width=11, + style="green.TButton" + ) + save_button.pack(side=tk.LEFT, padx=5) # Bind the Enter key to the Save button self.add_dialog.bind('', lambda event: save_button.invoke()) + + def scan_for_apps(self): + """Open app scanner dialog""" + if hasattr(self, 'add_dialog'): + self.add_dialog.destroy() # Close add dialog + self.app_manager.show_app_scanner_dialog() def on_drop(self, event): @@ -354,69 +385,7 @@ def create_widgets(self): - # Applications Tab - self.apps_frame = ttk.Frame(self.notebook) - self.notebook.add(self.apps_frame, text="Applications") - - # Create a frame to hold the listbox and scrollbar - list_frame = ttk.Frame(self.apps_frame) - list_frame.pack(pady=5, padx=5, fill=tk.BOTH, expand=True) - - # Create the listbox with a scrollbar - self.apps_listbox = tk.Listbox(list_frame, width=50, font=("Helvetica", 10), selectmode=tk.SINGLE) - self.apps_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - - scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=self.apps_listbox.yview) - scrollbar.pack(side=tk.RIGHT, fill=tk.Y) - - self.apps_listbox.config(yscrollcommand=scrollbar.set) - - - - self.update_apps_listbox() - - # Buttons frame - button_frame = ttk.Frame(self.apps_frame) - button_frame.pack(pady=10, padx=5, fill=tk.X) - - # Modify the Add button to open the new dialog - ttk.Button(button_frame, text="Add", command=self.open_add_application_dialog, style="green.TButton").pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="Remove", command=self.remove_application, style="red.TButton").pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="Rename", command=self.rename_application).pack(side=tk.LEFT, padx=5) - - - # State Tab - # self.state_frame = ttk.Frame(self.notebook) - # self.notebook.add(self.state_frame, text="State") - - # self.state_text = tk.Text(self.state_frame, width=60, height=10) - # self.state_text.pack(pady=5) - # self.update_state_display() - - - - - - - - - - - - - - - - - - - - - - - - - # Config Tab + # Config Tab (moved before Applications tab to match Linux version) # Config Tab with scrollable content self.config_frame = ttk.Frame(self.notebook) self.notebook.add(self.config_frame, text="Config") @@ -448,6 +417,24 @@ def create_widgets(self): self.config_text.pack(pady=5, padx=15, fill=tk.X, expand=False) self.update_config_display() + + # Applications Tab - Using shared ApplicationManager + self.app_manager = ApplicationManager( + app_locker=self.app_locker, + master=self.master, + notebook=self.notebook, + resource_path_func=self.resource_path, + show_message_func=self.show_message, + update_config_display_func=self.update_config_display, + is_linux=False + ) + + # Set callback for Add button + self.app_manager.add_application_callback = self.open_add_application_dialog + + # Keep references to ApplicationManager's components + self.apps_frame = self.app_manager.apps_frame + # Description below the config text box config_description = ttk.Label(scrollable_config_frame, text=( "This is the list of applications currently locked by FadCrypt.\n" @@ -1124,15 +1111,9 @@ def update_config_textbox(self): def update_apps_listbox(self): - self.apps_listbox.delete(0, tk.END) - for index, app in enumerate(self.app_locker.config["applications"]): - item = f" {app['name']} - {app['path']}" # Added two spaces for left padding - self.apps_listbox.insert(tk.END, item) - # Apply alternating row colors - if index % 2 == 0: - self.apps_listbox.itemconfig(index, {'bg': '#f0f0f0'}) - else: - self.apps_listbox.itemconfig(index, {'bg': '#ffffff'}) + """Delegate to ApplicationManager""" + if self.app_manager: + self.app_manager.update_apps_listbox() self.update_config_display() def update_config_display(self): @@ -1164,11 +1145,6 @@ def export_state(self): - def update_apps_listbox(self): - self.apps_listbox.delete(0, tk.END) - for app in self.app_locker.config["applications"]: - self.apps_listbox.insert(tk.END, f"{app['name']} - {app['path']}") - def create_password(self): if os.path.exists(self.app_locker.password_file): self.show_message("Info", "Password already exists. Use 'Change Password' to modify.") @@ -1192,42 +1168,20 @@ def change_password(self): self.show_message("Oops!", "How do I change a password that doesn't exist? :(") def add_application(self): - app_name = self.ask_password("Add Application", "Enter the name of the application:") - if app_name: - app_path = filedialog.askopenfilename(title="Select application executable") - if app_path: - self.app_locker.add_application(app_name, app_path) - self.update_apps_listbox() - self.update_config_display() # Update config tab - self.show_message("Success", f"Application {app_name}\nadded successfully.") + """Keep old dialog - ApplicationManager doesn't have Windows .exe dialog""" + # The open_add_application_dialog method handles this + pass def remove_application(self): - selection = self.apps_listbox.curselection() - if selection: - app_name = self.apps_listbox.get(selection[0]).split(" - ")[0].strip() # Remove leading spaces - self.app_locker.remove_application(app_name) - self.update_apps_listbox() - self.update_config_display() - self.show_message("Success", f"Application {app_name}\nremoved successfully.") - else: - self.show_message("Error", "Please select an application to remove.") + """Delegate to ApplicationManager""" + if self.app_manager: + self.app_manager.remove_applications() def rename_application(self): - selection = self.apps_listbox.curselection() - if selection: - old_name = self.apps_listbox.get(selection[0]).split(" - ")[0].strip() # Remove leading spaces - new_name = self.ask_password("Rename Application", f"Enter new name for {old_name}:") - if new_name: - for app in self.app_locker.config["applications"]: - if app["name"] == old_name: - app["name"] = new_name - break - self.update_apps_listbox() - self.update_config_display() - self.show_message("Success", f"Application renamed from {old_name} to {new_name}.") - else: - self.show_message("Error", "Please select an application to rename.") + """Delegate to ApplicationManager's edit function""" + if self.app_manager: + self.app_manager.edit_application() def start_monitoring(self, auto_start=False): if os.path.exists(self.app_locker.password_file): @@ -1493,8 +1447,17 @@ def ask_password(self, title, prompt): def custom_dialog(self, title, prompt, fullscreen=False, input_required=True): dialog = tk.Toplevel(self.master) dialog.attributes('-alpha', 0.0) # Start fully transparent + dialog.attributes('-topmost', True) # Always on top dialog.update_idletasks() # Update geometry-related information + # Set the FadCrypt icon for the dialog + try: + ico_path = self.resource_path('img/1.ico') + if os.path.exists(ico_path): + dialog.iconbitmap(ico_path) + except Exception as e: + print(f"Could not set dialog icon: {e}") + if fullscreen: dialog.attributes('-fullscreen', True) else: @@ -1608,6 +1571,14 @@ def full_screen_password_dialog(self, title, prompt): dialog.attributes('-fullscreen', True) dialog.grab_set() + # Set the FadCrypt icon for the dialog + try: + ico_path = self.resource_path('img/1.ico') + if os.path.exists(ico_path): + dialog.iconbitmap(ico_path) + except Exception as e: + print(f"Could not set dialog icon: {e}") + # Load and display wallpaper wallpaper_path = self.get_wallpaper_path() wallpaper = Image.open(wallpaper_path) diff --git a/FadCrypt_Linux.py b/FadCrypt_Linux.py index 5b783b5..b7e21c2 100644 --- a/FadCrypt_Linux.py +++ b/FadCrypt_Linux.py @@ -181,6 +181,7 @@ # Import shared core modules from core.config_manager import ConfigManager +from core.application_manager import ApplicationManager import atexit # App Version Information - imported from central version file @@ -258,15 +259,16 @@ def open_add_application_dialog(self): # Center the dialog on the screen screen_width = self.add_dialog.winfo_screenwidth() screen_height = self.add_dialog.winfo_screenheight() - dialog_width = 400 # Adjust width as needed - dialog_height = 500 # Adjust height as needed + dialog_width = 420 # Increased width slightly + dialog_height = 580 # Increased height to accommodate all content # position_x = (screen_width // 2) - (dialog_width // 2) position_x = 50 # Position the dialog on the left edge of the screen position_y = (screen_height // 2) - (dialog_height // 2) self.add_dialog.geometry(f"{dialog_width}x{dialog_height}+{position_x}+{position_y}") - # Prevent resizing - self.add_dialog.resizable(False, False) + # Allow resizing so user can expand if needed + self.add_dialog.resizable(True, True) + self.add_dialog.minsize(400, 550) # Set minimum size # Ensure the dialog is focused self.add_dialog.attributes('-topmost', True) @@ -325,12 +327,38 @@ def update_text_position(): browse_button = ttk.Button(manual_frame, text="Browse", command=self.browse_for_file, style="navy.TButton") browse_button.pack(pady=5) + # Buttons frame + buttons_frame = ttk.Frame(self.add_dialog) + buttons_frame.pack(pady=10) + + # Scan Apps Button (new feature!) + scan_button = ttk.Button( + buttons_frame, + text="šŸ” Scan for Apps", + command=self.scan_for_apps, + width=15, + style="navy.TButton" + ) + scan_button.pack(side=tk.LEFT, padx=5) + # Save Button - save_button = ttk.Button(self.add_dialog, text="Save", command=self.save_application, width=11, style="green.TButton") - save_button.pack(pady=10) + save_button = ttk.Button( + buttons_frame, + text="šŸ’¾ Save", + command=self.save_application, + width=11, + style="green.TButton" + ) + save_button.pack(side=tk.LEFT, padx=5) # Bind the Enter key to the Save button self.add_dialog.bind('', lambda event: save_button.invoke()) + + def scan_for_apps(self): + """Open app scanner dialog""" + if hasattr(self, 'add_dialog'): + self.add_dialog.destroy() # Close add dialog + self.app_manager.show_app_scanner_dialog() def on_drop(self, event): @@ -580,48 +608,23 @@ def create_widgets(self): import_button = ttk.Button(buttons_container, text="Import Config", command=self.import_config, style="blue.TButton") import_button.pack(side="left") - # Applications Tab - self.apps_frame = ttk.Frame(self.notebook) - self.notebook.add(self.apps_frame, text="Applications") - - # Create a frame to hold the listbox and scrollbar - list_frame = ttk.Frame(self.apps_frame) - list_frame.pack(pady=5, padx=5, fill=tk.BOTH, expand=True) - - # Create the listbox with a scrollbar - self.apps_listbox = tk.Listbox( - list_frame, - width=50, - font=("Helvetica", 10), - selectmode=tk.SINGLE, - bg='#222222', # Dark background matching darkly theme - fg='#ffffff', # White text - selectbackground='#555555', # Gray selection background - selectforeground='#009E60', # Green text when selected (matching theme) - activestyle='none', # Remove underline on active item - highlightcolor='#ED2939', # Red border when focused (matching theme active color) - highlightbackground='#444444', # Dark border when not focused - highlightthickness=1 + # Applications Tab - Using shared ApplicationManager + self.app_manager = ApplicationManager( + app_locker=self.app_locker, + master=self.master, + notebook=self.notebook, + resource_path_func=self.resource_path, + show_message_func=self.show_message, + update_config_display_func=self.update_config_display, + is_linux=True ) - self.apps_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - - scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=self.apps_listbox.yview) - scrollbar.pack(side=tk.RIGHT, fill=tk.Y) - - self.apps_listbox.config(yscrollcommand=scrollbar.set) - - - self.update_apps_listbox() - - # Buttons frame - button_frame = ttk.Frame(self.apps_frame) - button_frame.pack(pady=10, padx=5, fill=tk.X) - - # Modify the Add button to open the new dialog - ttk.Button(button_frame, text="Add", command=self.open_add_application_dialog, style="green.TButton").pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="Remove", command=self.remove_application, style="red.TButton").pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="Rename", command=self.rename_application).pack(side=tk.LEFT, padx=5) + # Set callback for Add button + self.app_manager.add_application_callback = self.open_add_application_dialog + + # Keep references to ApplicationManager's components + self.apps_frame = self.app_manager.apps_frame + self.app_count_label = self.app_manager.app_count_label @@ -1237,16 +1240,8 @@ def update_config_textbox(self): def update_apps_listbox(self): - self.apps_listbox.delete(0, tk.END) - for index, app in enumerate(self.app_locker.config["applications"]): - item = f" {app['name']} - {app['path']}" # Added two spaces for left padding - self.apps_listbox.insert(tk.END, item) - # Apply alternating row colors for dark theme - if index % 2 == 0: - self.apps_listbox.itemconfig(index, {'bg': '#2a2a2a', 'fg': '#ffffff'}) - else: - self.apps_listbox.itemconfig(index, {'bg': '#1f1f1f', 'fg': '#ffffff'}) - self.update_config_display() + """Delegate to ApplicationManager""" + self.app_manager.update_apps_listbox() def update_config_display(self): self.config_text.config(state=tk.NORMAL) @@ -1310,31 +1305,24 @@ def add_application(self): def remove_application(self): - selection = self.apps_listbox.curselection() - if selection: - app_name = self.apps_listbox.get(selection[0]).split(" - ")[0].strip() # Remove leading spaces - self.app_locker.remove_application(app_name) - self.update_apps_listbox() - self.update_config_display() - self.show_message("Success", f"Application {app_name}\nremoved successfully.") - else: - self.show_message("Error", "Please select an application to remove.") + """Delegate to ApplicationManager""" + self.app_manager.remove_applications() + + def edit_application(self): + """Delegate to ApplicationManager""" + self.app_manager.edit_application() + + def select_all_apps(self): + """Delegate to ApplicationManager""" + return self.app_manager.select_all_apps() + + def deselect_all_apps(self): + """Delegate to ApplicationManager""" + self.app_manager.deselect_all_apps() def rename_application(self): - selection = self.apps_listbox.curselection() - if selection: - old_name = self.apps_listbox.get(selection[0]).split(" - ")[0].strip() # Remove leading spaces - new_name = self.ask_password("Rename Application", f"Enter new name for {old_name}:") - if new_name: - for app in self.app_locker.config["applications"]: - if app["name"] == old_name: - app["name"] = new_name - break - self.update_apps_listbox() - self.update_config_display() - self.show_message("Success", f"Application renamed from {old_name} to {new_name}.") - else: - self.show_message("Error", "Please select an application to rename.") + """Legacy method - redirects to edit_application""" + self.edit_application() def start_monitoring(self, auto_start=False): if os.path.exists(self.app_locker.password_file): @@ -1630,6 +1618,7 @@ def ask_password(self, title, prompt): def custom_dialog(self, title, prompt, fullscreen=False, input_required=True): dialog = tk.Toplevel(self.master) dialog.attributes('-alpha', 0.0) # Start fully transparent + dialog.attributes('-topmost', True) # Always on top dialog.update_idletasks() # Update geometry-related information if fullscreen: @@ -2614,86 +2603,85 @@ def remove_application(self, app_name): self.save_config() def block_application(self, app_name, app_path): + # Cache process name to avoid repeated path parsing + process_name = os.path.basename(app_path) if app_path else app_name + if app_path.endswith('.desktop'): + process_name = self._get_exec_from_desktop(app_path) + + process_name_lower = process_name.lower() + is_chrome_based = 'chrome' in process_name_lower + while self.monitoring: try: - # Get process name from path for better matching - process_name = os.path.basename(app_path) if app_path else app_name - - # Handle .desktop files - if app_path.endswith('.desktop'): - process_name = self._get_exec_from_desktop(app_path) - app_processes = [] - for proc in psutil.process_iter(['name', 'pid', 'cmdline', 'status', 'ppid']): + + # Optimized process iteration - only get needed attributes + for proc in psutil.process_iter(['name', 'pid', 'cmdline', 'status']): try: - # Skip zombie processes - they are already dead but not reaped + # Skip zombie processes immediately if proc.info['status'] == psutil.STATUS_ZOMBIE: - continue # Skip zombie processes silently + continue - # Match by process name or command line proc_name = proc.info['name'].lower() - proc_cmdline = proc.info['cmdline'] if proc.info['cmdline'] else [] - # Direct name match - if proc_name == process_name.lower(): + # Fast path: direct name match + if proc_name == process_name_lower: app_processes.append(proc) - # Command line match - elif proc_cmdline and any(process_name.lower() in cmd.lower() for cmd in proc_cmdline if cmd): - app_processes.append(proc) - # Special handling for Chrome/Chromium-based apps - catch all child processes - elif 'chrome' in process_name.lower() and ('chrome' in proc_name or - any('chrome' in str(cmd).lower() for cmd in proc_cmdline if cmd)): + continue + + # Chrome-based apps: check chrome in name + if is_chrome_based and 'chrome' in proc_name: app_processes.append(proc) + continue + + # Slower path: check command line if available + proc_cmdline = proc.info.get('cmdline') + if proc_cmdline: + cmdline_str = ' '.join(proc_cmdline).lower() + if process_name_lower in cmdline_str: + app_processes.append(proc) + elif is_chrome_based and 'chrome' in cmdline_str: + app_processes.append(proc) + except (psutil.NoSuchProcess, psutil.AccessDenied): continue if app_processes: - print(f"Found {len(app_processes)} processes for {app_name}, unlocked_apps: {self.state['unlocked_apps']}, showing_dialog: {app_name in self.apps_showing_dialog}") if app_name not in self.state["unlocked_apps"]: if app_name not in self.apps_showing_dialog: print(f"Blocking {app_name}: terminating {len(app_processes)} processes") + + # Kill processes efficiently for proc in app_processes: - print(f"Killing process {proc.info['pid']}: {proc.info['name']} - cmdline: {proc.info['cmdline']}") - # Kill child processes first - for child in proc.children(recursive=True): - try: - child.kill() - print(f"Killed child process {child.pid}") - except (psutil.NoSuchProcess, psutil.AccessDenied) as e: - print(f"Failed to kill child {child.pid}: {e}") try: - proc.kill() # Use kill instead of terminate for immediate blocking - except (psutil.NoSuchProcess, psutil.AccessDenied) as e: - print(f"Failed to kill process {proc.info['pid']}: {e}") + # Kill children first (don't recurse too deep to save CPU) + for child in proc.children(recursive=False): + try: + child.kill() + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass + proc.kill() + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass - print(f"Showing password dialog for {app_name}") self.apps_showing_dialog.add(app_name) self.gui.master.after(0, self._show_password_dialog, app_name, app_path) - time.sleep(1) else: - # App is showing dialog, but new processes appeared - kill them too - print(f"Blocking additional {app_name} processes while dialog is showing: terminating {len(app_processes)} processes") + # Kill additional processes while dialog is showing for proc in app_processes: - print(f"Killing additional process {proc.info['pid']}: {proc.info['name']} - cmdline: {proc.info['cmdline']}") - # Kill child processes first - for child in proc.children(recursive=True): - try: - child.kill() - print(f"Killed child process {child.pid}") - except (psutil.NoSuchProcess, psutil.AccessDenied) as e: - print(f"Failed to kill child {child.pid}: {e}") try: - proc.kill() # Use kill instead of terminate for immediate blocking - except (psutil.NoSuchProcess, psutil.AccessDenied) as e: - print(f"Failed to kill process {proc.info['pid']}: {e}") + proc.kill() + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass else: - print(f"{app_name} is unlocked, sleeping for 7 seconds") - time.sleep(7) + # Unlocked - reset counter and skip checks for longer + if hasattr(self, f"{app_name}_no_process_count"): + delattr(self, f"{app_name}_no_process_count") + time.sleep(5) # Longer sleep when unlocked + continue - # Only remove from unlocked_apps if no processes found for an extended period - # This prevents premature removal right after launching + # Auto-lock logic when no processes found if app_name in self.state["unlocked_apps"] and not app_processes: - # Check if we've seen no processes for this app recently if not hasattr(self, f"{app_name}_no_process_count"): setattr(self, f"{app_name}_no_process_count", 0) @@ -2701,21 +2689,22 @@ def block_application(self, app_name, app_path): count += 1 setattr(self, f"{app_name}_no_process_count", count) - # Only remove after 10 consecutive checks with no processes (about 0.5 seconds) + # Auto-lock after 10 checks with no processes if count >= 10: print(f"Auto-locking {app_name} (no active processes found)") self.state["unlocked_apps"].remove(app_name) self.save_state() delattr(self, f"{app_name}_no_process_count") - # Don't print the counting to avoid terminal clutter elif app_name in self.state["unlocked_apps"] and app_processes: - # Reset counter if processes are found if hasattr(self, f"{app_name}_no_process_count"): delattr(self, f"{app_name}_no_process_count") - - time.sleep(1) # Check every 1 second - balances responsiveness and CPU usage + + # CRITICAL: Always sleep to prevent CPU spike + time.sleep(0.1) # 100ms check interval - responsive but efficient + except Exception as e: - print(f"Error in block_application: {e}") + print(f"Error in block_application for {app_name}: {e}") + time.sleep(1) # Sleep longer on error def _get_exec_from_desktop(self, desktop_path): """Extract executable name from .desktop file""" diff --git a/README.md b/README.md index 4a0dd79..2223ee2 100644 --- a/README.md +++ b/README.md @@ -171,11 +171,36 @@ We look forward to your contributions! # Install Dependencies & Build -```python -pip install cryptography psutil pillow pystray watchdog tkinterdnd2 ttkbootstrap pygame requests +**Linux Prerequisites:** + +First, install the Tkinter library (required for GUI): + +```bash +sudo apt-get install python3-tk +``` + +**Install Python Dependencies:** + +You can install all required Python packages using pip: + +```bash +pip install -r requirements.txt +``` + +**Build the Application:** + +For Windows: + +```bash python -m PyInstaller FadCrypt.spec ``` +For Linux: + +```bash +python3 -m PyInstaller FadCrypt_Linux.spec +``` + # Reset Password Follow the steps below to regain access to FadCrypt, or download the guide as a PDF for reference: diff --git a/core/README.md b/core/README.md index c37eb8b..883de55 100644 --- a/core/README.md +++ b/core/README.md @@ -37,6 +37,44 @@ config_mgr.export_config(show_message_func) config_mgr.import_config(show_message_func, update_display_func) ``` +### `application_manager.py` + +Manages the Applications tab, application CRUD operations, statistics, and metadata tracking. + +**Features:** + +- Complete Applications tab UI with multi-selection support +- Add, edit, remove applications with validation +- Timestamp tracking (added, modified) +- Usage statistics (unlock count, last unlocked) +- Context menu (right-click) with quick actions +- Keyboard shortcuts (Ctrl+A, Delete, Double-click, F2) +- Confirmation dialogs for destructive operations +- Metadata persistence (stored in `app_metadata.json`) +- Icon support with caching (extracts from .desktop files on Linux) + +**Usage:** + +```python +from core.application_manager import ApplicationManager + +# Initialize +app_manager = ApplicationManager( + app_locker=your_app_locker, + master=your_tk_window, + notebook=your_notebook, + resource_path_func=your_resource_path_function, + show_message_func=your_message_function, + update_config_display_func=your_update_function, + is_linux=True # or False for Windows +) + +# The tab is automatically created +# Methods available: +app_manager.increment_unlock_count(app_name) # Track unlocks +app_manager.update_apps_listbox() # Refresh display +``` + ## Adding New Shared Modules When adding new features that are identical between Windows and Linux versions: @@ -58,6 +96,11 @@ When adding new features that are identical between Windows and Linux versions: Consider extracting these into core modules: +- Password management (create, change, verify) +- Update checking functionality +- Backup and restore operations +- System monitoring and tray icon management + - Password management (create/change/verify) - Update checking logic - Backup/restore functionality diff --git a/core/application_manager.py b/core/application_manager.py new file mode 100644 index 0000000..039a43d --- /dev/null +++ b/core/application_manager.py @@ -0,0 +1,1267 @@ +""" +Application Manager Module for FadCrypt + +This module handles all application management functionality including: +- Adding, editing, and removing applications +- Multi-selection operations +- Statistics tracking (unlock counts, last accessed) +- Metadata (timestamps for added/modified) +- UI components for application list display +""" + +import os +import json +import time +import tkinter as tk +from tkinter import ttk, filedialog, messagebox +from datetime import datetime +from typing import Callable, Optional, Dict, List, Any +from PIL import Image, ImageTk + + +def format_timestamp(timestamp: float) -> str: + """ + Format timestamp to readable format: '24th Aug 2025 2:24 PM' + """ + dt = datetime.fromtimestamp(timestamp) + + # Get day with ordinal suffix + day = dt.day + if 4 <= day <= 20 or 24 <= day <= 30: + suffix = "th" + else: + suffix = ["st", "nd", "rd"][day % 10 - 1] + + # Format: 24th Aug 2025 2:24 PM + return dt.strftime(f"{day}{suffix} %b %Y %I:%M %p") + + +class ApplicationManager: + """ + Manages application data, operations, and UI components for FadCrypt. + Handles the applications tab, edit dialogs, statistics, and metadata. + """ + + def __init__(self, app_locker, master, notebook, resource_path_func, + show_message_func, update_config_display_func, is_linux=True): + """ + Initialize ApplicationManager + + Args: + app_locker: The AppLocker instance with config + master: Main Tk window + notebook: ttk.Notebook to add the Applications tab to + resource_path_func: Function to get resource paths + show_message_func: Function to show messages + update_config_display_func: Function to update config display + is_linux: Whether running on Linux (affects executable detection) + """ + self.app_locker = app_locker + self.master = master + self.notebook = notebook + self.resource_path = resource_path_func + self.show_message = show_message_func + self.update_config_display = update_config_display_func + self.is_linux = is_linux + + # UI components + self.app_count_label = None + self.apps_frame = None + self.apps_container = None # Scrollable container for grid + self.apps_canvas = None # Canvas for scrolling + self.selected_apps = [] # Track selected application cards + self.app_cards = [] # Store card widgets for selection + self.selected_cards = set() # Track selected cards + self.app_count_label = None + self.apps_frame = None + + # Icon cache + self.icon_cache = {} + + # Callback for adding applications (set by parent GUI) + self.add_application_callback = None + + # Initialize metadata storage + self.metadata_file = os.path.join( + os.path.dirname(self.app_locker.config_file), + 'app_metadata.json' + ) + self.metadata = self.load_metadata() + + # Create the Applications tab + self.create_applications_tab() + + def load_metadata(self) -> Dict: + """Load application metadata (timestamps, stats) from file""" + if os.path.exists(self.metadata_file): + try: + with open(self.metadata_file, 'r') as f: + return json.load(f) + except Exception as e: + print(f"Error loading metadata: {e}") + return {} + return {} + + def save_metadata(self): + """Save application metadata to file""" + try: + with open(self.metadata_file, 'w') as f: + json.dump(self.metadata, f, indent=4) + except Exception as e: + print(f"Error saving metadata: {e}") + + def get_app_metadata(self, app_name: str) -> Dict: + """Get metadata for a specific app, create if doesn't exist""" + if app_name not in self.metadata: + self.metadata[app_name] = { + 'added_timestamp': time.time(), + 'modified_timestamp': time.time(), + 'unlock_count': 0, + 'last_unlocked': None + } + self.save_metadata() + return self.metadata[app_name] + + def increment_unlock_count(self, app_name: str): + """Increment unlock count for an app""" + meta = self.get_app_metadata(app_name) + meta['unlock_count'] += 1 + meta['last_unlocked'] = time.time() + self.save_metadata() + + def update_modified_timestamp(self, app_name: str): + """Update the modified timestamp for an app""" + meta = self.get_app_metadata(app_name) + meta['modified_timestamp'] = time.time() + self.save_metadata() + + def get_app_icon(self, app_path: str, size=(32, 32)) -> Optional[ImageTk.PhotoImage]: + """ + Get icon for an application. + Returns cached icon if available, otherwise tries to extract/load icon. + """ + print(f"\nšŸ” [ICON LOADING] Attempting to load icon for: {app_path}") + + if app_path in self.icon_cache: + print(f"āœ… [ICON CACHE] Found cached icon for: {app_path}") + return self.icon_cache[app_path] + + try: + icon_path = None + + # Try to get icon from .desktop file on Linux + if self.is_linux: + print(f"🐧 [LINUX] Searching for .desktop file...") + icon_path = self.find_desktop_icon(app_path) + if icon_path: + print(f"āœ… [DESKTOP] Found icon from .desktop file: {icon_path}") + else: + print(f"āŒ [DESKTOP] No icon found in .desktop files") + + # If no icon found, try to find by app name + if not icon_path or not os.path.exists(icon_path): + app_name = os.path.basename(app_path).lower() + print(f"šŸ”Ž [NAME SEARCH] Searching by app name: {app_name}") + # Try common icon locations + icon_path = self.find_icon_by_name(app_name) + if icon_path: + print(f"āœ… [NAME SEARCH] Found icon: {icon_path}") + else: + print(f"āŒ [NAME SEARCH] No icon found by name") + + # Load the icon if found + if icon_path and os.path.exists(icon_path): + print(f"šŸ“ [FILE CHECK] Icon file exists: {icon_path}") + # Handle SVG files + if icon_path.endswith('.svg'): + print(f"šŸŽØ [SVG] Found SVG file, looking for PNG alternative...") + # For SVG, we'll use a default icon or convert + # For now, try to find PNG version + png_path = icon_path.replace('.svg', '.png') + if os.path.exists(png_path): + icon_path = png_path + print(f"āœ… [SVG->PNG] Using PNG version: {png_path}") + else: + # Try without extension + icon_path = self.find_icon_by_name(os.path.splitext(os.path.basename(icon_path))[0]) + if icon_path: + print(f"āœ… [SVG->PNG] Found alternative: {icon_path}") + + if icon_path and icon_path.endswith(('.png', '.jpg', '.jpeg', '.xpm')): + print(f"šŸ–¼ļø [IMAGE LOAD] Loading image file: {icon_path}") + image = Image.open(icon_path) + image = image.resize(size, Image.Resampling.LANCZOS) + photo = ImageTk.PhotoImage(image) + self.icon_cache[app_path] = photo + print(f"āœ… [SUCCESS] Icon loaded and cached successfully!") + return photo + else: + print(f"āš ļø [FORMAT] Unsupported format or no valid path: {icon_path}") + else: + print(f"āŒ [NOT FOUND] Icon path not found or doesn't exist") + except Exception as e: + print(f"āŒ [ERROR] Error loading icon for {app_path}: {e}") + import traceback + traceback.print_exc() + + print(f"āŒ [FINAL] Returning None - no icon loaded for {app_path}") + return None + + def find_icon_by_name(self, app_name: str) -> Optional[str]: + """Find icon by application name in standard icon directories""" + # Remove common suffixes + app_name = app_name.replace('-browser', '').replace('.bin', '').replace('.exe', '') + + # Common icon directories and sizes + icon_locations = [ + f'/usr/share/pixmaps/{app_name}.png', + f'/usr/share/pixmaps/{app_name}.xpm', + f'/usr/share/icons/hicolor/48x48/apps/{app_name}.png', + f'/usr/share/icons/hicolor/32x32/apps/{app_name}.png', + f'/usr/share/icons/hicolor/256x256/apps/{app_name}.png', + f'/usr/share/icons/hicolor/scalable/apps/{app_name}.svg', + f'/usr/share/app-install/icons/{app_name}.png', + ] + + for path in icon_locations: + if os.path.exists(path): + return path + + # Try with capital first letter + app_name_cap = app_name.capitalize() + for path in icon_locations: + cap_path = path.replace(app_name, app_name_cap) + if os.path.exists(cap_path): + return cap_path + + return None + + def find_desktop_icon(self, app_path: str) -> Optional[str]: + """Find icon path from .desktop file on Linux""" + try: + # Common desktop file locations + desktop_dirs = [ + '/usr/share/applications', + '/usr/local/share/applications', + os.path.expanduser('~/.local/share/applications') + ] + + app_name = os.path.basename(app_path) + + for desktop_dir in desktop_dirs: + if not os.path.exists(desktop_dir): + continue + + for desktop_file in os.listdir(desktop_dir): + if not desktop_file.endswith('.desktop'): + continue + + desktop_path = os.path.join(desktop_dir, desktop_file) + try: + with open(desktop_path, 'r') as f: + content = f.read() + if app_name in content or app_path in content: + # Extract Icon= line + for line in content.split('\n'): + if line.startswith('Icon='): + icon_name = line.split('=', 1)[1].strip() + # Try to find the actual icon file + icon_path = self.find_icon_path(icon_name) + if icon_path: + return icon_path + except: + continue + except Exception as e: + print(f"Error finding desktop icon: {e}") + return None + + def find_icon_path(self, icon_name: str) -> Optional[str]: + """Find full path for an icon name""" + # Common icon directories + icon_dirs = [ + '/usr/share/icons/hicolor/48x48/apps', + '/usr/share/icons/hicolor/32x32/apps', + '/usr/share/pixmaps', + '/usr/share/icons' + ] + + # If already a full path + if os.path.exists(icon_name): + return icon_name + + # Search in icon directories + for icon_dir in icon_dirs: + if not os.path.exists(icon_dir): + continue + + # Try various extensions + for ext in ['.png', '.svg', '.xpm', '']: + icon_path = os.path.join(icon_dir, icon_name + ext) + if os.path.exists(icon_path): + return icon_path + + return None + + def create_applications_tab(self): + """Create the Applications tab with grid-based UI components""" + self.apps_frame = ttk.Frame(self.notebook) + self.notebook.add(self.apps_frame, text="Applications") + + # Header frame with app count + header_frame = ttk.Frame(self.apps_frame) + header_frame.pack(pady=(5, 0), padx=10, fill=tk.X) + + self.app_count_label = ttk.Label( + header_frame, + text="Applications: 0", + font=("TkDefaultFont", 11, "bold") + ) + self.app_count_label.pack(side=tk.LEFT, padx=5) + + # Create scrollable canvas for grid + canvas_frame = ttk.Frame(self.apps_frame) + canvas_frame.pack(pady=5, padx=5, fill=tk.BOTH, expand=True) + + # Canvas with scrollbar + self.apps_canvas = tk.Canvas( + canvas_frame, + bg='#1e1e1e', + highlightthickness=0 + ) + scrollbar = ttk.Scrollbar( + canvas_frame, + orient=tk.VERTICAL, + command=self.apps_canvas.yview + ) + + self.apps_canvas.configure(yscrollcommand=scrollbar.set) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + self.apps_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + # Container frame inside canvas for grid + self.apps_container = ttk.Frame(self.apps_canvas) + self.canvas_window = self.apps_canvas.create_window( + (0, 0), + window=self.apps_container, + anchor='nw' + ) + + # Bind canvas resize + self.apps_container.bind('', self._on_frame_configure) + self.apps_canvas.bind('', self._on_canvas_configure) + + # Mouse wheel scrolling - only on canvas, not bind_all + self.apps_canvas.bind("", self._on_mousewheel) + self.apps_canvas.bind("", self._on_mousewheel) + self.apps_canvas.bind("", self._on_mousewheel) + + self.update_apps_listbox() + + # Buttons frame + button_frame = ttk.Frame(self.apps_frame) + button_frame.pack(pady=10, padx=5, fill=tk.X) + + # Left side buttons (main actions) + left_buttons = ttk.Frame(button_frame) + left_buttons.pack(side=tk.LEFT) + + ttk.Button( + left_buttons, + text="āž• Add", + command=self.on_add_button_click, + style="green.TButton" + ).pack(side=tk.LEFT, padx=5) + + ttk.Button( + left_buttons, + text="šŸ—‘ļø Remove", + command=self.remove_applications, + style="red.TButton" + ).pack(side=tk.LEFT, padx=5) + + ttk.Button( + left_buttons, + text="āœļø Edit", + command=self.edit_application + ).pack(side=tk.LEFT, padx=5) + + # Right side buttons (selection actions) + right_buttons = ttk.Frame(button_frame) + right_buttons.pack(side=tk.RIGHT) + + # Create custom style for Select All button with white text + style = ttk.Style() + style.configure("SelectAll.TButton", foreground="white") + + ttk.Button( + right_buttons, + text="Select All", + command=self.select_all_apps, + style="SelectAll.TButton" + ).pack(side=tk.LEFT, padx=5) + + ttk.Button( + right_buttons, + text="Deselect All", + command=self.deselect_all_apps + ).pack(side=tk.LEFT, padx=5) + + def on_add_button_click(self): + """Handle Add button click - calls callback if set""" + if self.add_application_callback: + self.add_application_callback() + else: + print("Add application callback not set") + + def scan_installed_applications(self): + """Scan system for installed applications (cross-platform)""" + apps = [] + + if self.is_linux: + # Scan .desktop files + desktop_dirs = [ + '/usr/share/applications', + '/usr/local/share/applications', + os.path.expanduser('~/.local/share/applications') + ] + + for desktop_dir in desktop_dirs: + if not os.path.exists(desktop_dir): + continue + + for filename in os.listdir(desktop_dir): + if not filename.endswith('.desktop'): + continue + + filepath = os.path.join(desktop_dir, filename) + try: + with open(filepath, 'r') as f: + name = None + exec_path = None + icon = None + no_display = False + + for line in f: + line = line.strip() + if line.startswith('Name='): + name = line.split('=', 1)[1] + elif line.startswith('Exec='): + exec_line = line.split('=', 1)[1] + # Extract executable path (remove %u, %f etc.) + exec_parts = exec_line.split() + if exec_parts: + exec_path = exec_parts[0] + elif line.startswith('Icon='): + icon = line.split('=', 1)[1] + elif line.startswith('NoDisplay=true'): + no_display = True + + # Only add if has name, exec, not hidden, and not already in config + if name and exec_path and not no_display: + # Check if already added + already_added = any( + app['name'] == name or app['path'] == exec_path + for app in self.app_locker.config["applications"] + ) + + if not already_added: + apps.append({ + 'name': name, + 'path': exec_path, + 'icon': icon, + 'desktop_file': filepath + }) + except Exception as e: + print(f"Error reading {filepath}: {e}") + else: + # Windows: Scan common program directories + program_dirs = [ + r"C:\Program Files", + r"C:\Program Files (x86)", + os.path.expanduser(r"~\AppData\Local\Programs") + ] + + for prog_dir in program_dirs: + if not os.path.exists(prog_dir): + continue + + for root, dirs, files in os.walk(prog_dir): + for file in files: + if file.endswith('.exe'): + filepath = os.path.join(root, file) + name = os.path.splitext(file)[0] + + # Check if already added + already_added = any( + app['path'] == filepath + for app in self.app_locker.config["applications"] + ) + + if not already_added: + apps.append({ + 'name': name, + 'path': filepath, + 'icon': filepath # Windows can extract icon from exe + }) + + # Sort by name + apps.sort(key=lambda x: x['name'].lower()) + return apps + + def show_app_scanner_dialog(self): + """Show dialog with scanned applications for batch adding""" + # Create loading dialog + loading_dialog = tk.Toplevel(self.master) + loading_dialog.title("Scanning Applications") + loading_dialog.geometry("400x150") + loading_dialog.transient(self.master) + loading_dialog.grab_set() + loading_dialog.resizable(False, False) + + # Center the loading dialog + loading_dialog.update_idletasks() + x = (loading_dialog.winfo_screenwidth() // 2) - (400 // 2) + y = (loading_dialog.winfo_screenheight() // 2) - (150 // 2) + loading_dialog.geometry(f"400x150+{x}+{y}") + + # Loading content + loading_frame = ttk.Frame(loading_dialog, padding=20) + loading_frame.pack(fill=tk.BOTH, expand=True) + + loading_label = ttk.Label(loading_frame, text="šŸ” Scanning system for applications...", + font=("Arial", 12, "bold")) + loading_label.pack(pady=10) + + progress = ttk.Progressbar(loading_frame, mode='indeterminate', length=300) + progress.pack(pady=10) + progress.start(10) + + status_label = ttk.Label(loading_frame, text="This may take a few seconds...", + font=("Arial", 9), foreground="gray") + status_label.pack(pady=5) + + # Force update to show the loading dialog + loading_dialog.update() + + # Scan for applications (this may take a few seconds) + try: + scanned_apps = self.scan_installed_applications() + loading_dialog.destroy() + except Exception as e: + loading_dialog.destroy() + self.show_message("Scan Error", f"Failed to scan applications: {str(e)}", "error") + return + + if not scanned_apps: + self.show_message("No Apps Found", "No new applications found to add.", "info") + return + + # Create dialog + dialog = tk.Toplevel(self.master) + dialog.title(f"Scan Applications - Found {len(scanned_apps)} apps") + dialog.attributes('-topmost', True) + + # Set size and position + dialog_width = 800 + dialog_height = 600 + screen_width = dialog.winfo_screenwidth() + screen_height = dialog.winfo_screenheight() + x = (screen_width // 2) - (dialog_width // 2) + y = (screen_height // 2) - (dialog_height // 2) + dialog.geometry(f"{dialog_width}x{dialog_height}+{x}+{y}") + dialog.resizable(True, True) + + # Main frame + main_frame = ttk.Frame(dialog, padding="10") + main_frame.pack(fill=tk.BOTH, expand=True) + + # Title + title_label = ttk.Label( + main_frame, + text=f"šŸ“¦ Found {len(scanned_apps)} Applications", + font=("TkDefaultFont", 14, "bold") + ) + title_label.pack(pady=(0, 10)) + + # Instructions + inst_label = ttk.Label( + main_frame, + text="Select applications to add (Ctrl+Click for multiple, Shift+Click for range):", + font=("TkDefaultFont", 10) + ) + inst_label.pack(pady=(0, 5)) + + # Create scrollable canvas for grid + canvas_frame = ttk.Frame(main_frame) + canvas_frame.pack(fill=tk.BOTH, expand=True, pady=5) + + scan_canvas = tk.Canvas(canvas_frame, bg='#1e1e1e', highlightthickness=0) + scan_scrollbar = ttk.Scrollbar(canvas_frame, orient=tk.VERTICAL, command=scan_canvas.yview) + scan_canvas.configure(yscrollcommand=scan_scrollbar.set) + + scan_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + scan_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + scan_container = ttk.Frame(scan_canvas) + canvas_window = scan_canvas.create_window((0, 0), window=scan_container, anchor='nw') + + # Track selected apps + selected_scan_apps = [] + + # Create grid of scanned app cards + columns = 4 + for index, app in enumerate(scanned_apps): + row = index // columns + col = index % columns + + # Create mini card + card = tk.Frame( + scan_container, + bg='#2a2a2a', + relief=tk.RAISED, + borderwidth=1, + highlightthickness=2, + highlightbackground='#444444' + ) + card.grid(row=row, column=col, padx=5, pady=5, sticky='nsew') + card.app_data = app + card.is_selected = False + + inner = tk.Frame(card, bg='#2a2a2a') + inner.pack(fill=tk.BOTH, expand=True, padx=8, pady=8) + + # Icon (smaller) + icon_photo = self.get_app_icon(app['path']) + if icon_photo: + try: + icon_path = self.find_desktop_icon(app['path']) + if icon_path and os.path.exists(icon_path): + img = Image.open(icon_path) + img = img.resize((32, 32), Image.Resampling.LANCZOS) + photo = ImageTk.PhotoImage(img) + icon_label = tk.Label(inner, image=photo, bg='#2a2a2a') + icon_label.image = photo + icon_label.pack() + except: + tk.Label(inner, text="šŸ“¦", font=("TkDefaultFont", 16), bg='#2a2a2a').pack() + else: + tk.Label(inner, text="šŸ“¦", font=("TkDefaultFont", 16), bg='#2a2a2a').pack() + + # Name + name_label = tk.Label( + inner, + text=app['name'], + font=("TkDefaultFont", 9), + bg='#2a2a2a', + fg='#ffffff', + wraplength=120 + ) + name_label.pack(pady=(5, 0)) + + # Click to select/deselect + def make_click_handler(card_widget): + def on_click(e): + if card_widget.is_selected: + card_widget.configure(highlightbackground='#444444') + card_widget.is_selected = False + if card_widget.app_data in selected_scan_apps: + selected_scan_apps.remove(card_widget.app_data) + else: + card_widget.configure(highlightbackground='#009E60', highlightthickness=3) + card_widget.is_selected = True + if card_widget.app_data not in selected_scan_apps: + selected_scan_apps.append(card_widget.app_data) + status_label.config(text=f"Selected: {len(selected_scan_apps)} apps") + return on_click + + for widget in [card, inner, name_label]: + widget.bind('', make_click_handler(card)) + + # Configure grid + for col in range(columns): + scan_container.grid_columnconfigure(col, weight=1, minsize=150) + + # Bind canvas configure + def on_scan_configure(e): + scan_canvas.configure(scrollregion=scan_canvas.bbox("all")) + scan_container.bind('', on_scan_configure) + + def on_canvas_configure(e): + scan_canvas.itemconfig(canvas_window, width=e.width) + scan_canvas.bind('', on_canvas_configure) + + # Status label + status_label = ttk.Label( + main_frame, + text="Selected: 0 apps", + font=("TkDefaultFont", 10, "bold") + ) + status_label.pack(pady=5) + + # Buttons + button_frame = ttk.Frame(main_frame) + button_frame.pack(pady=10) + + def select_all(): + for widget in scan_container.winfo_children(): + if hasattr(widget, 'app_data') and not widget.is_selected: + widget.configure(highlightbackground='#009E60', highlightthickness=3) + widget.is_selected = True + if widget.app_data not in selected_scan_apps: + selected_scan_apps.append(widget.app_data) + status_label.config(text=f"Selected: {len(selected_scan_apps)} apps") + + def deselect_all(): + for widget in scan_container.winfo_children(): + if hasattr(widget, 'app_data') and widget.is_selected: + widget.configure(highlightbackground='#444444', highlightthickness=2) + widget.is_selected = False + selected_scan_apps.clear() + status_label.config(text="Selected: 0 apps") + + def add_selected(): + if not selected_scan_apps: + self.show_message("No Selection", "Please select at least one application to add.") + return + + # Add all selected apps + for app in selected_scan_apps: + self.app_locker.add_application(app['name'], app['path']) + + dialog.destroy() + self.update_apps_listbox() + self.show_message("Success", f"Added {len(selected_scan_apps)} application(s) successfully!") + + ttk.Button(button_frame, text="Select All", command=select_all).pack(side=tk.LEFT, padx=5) + ttk.Button(button_frame, text="Deselect All", command=deselect_all).pack(side=tk.LEFT, padx=5) + ttk.Button(button_frame, text=f"āž• Add Selected", command=add_selected, style="green.TButton").pack(side=tk.LEFT, padx=5) + ttk.Button(button_frame, text="Cancel", command=dialog.destroy).pack(side=tk.LEFT, padx=5) + + def _on_frame_configure(self, event=None): + """Reset the scroll region to encompass the inner frame""" + # Update scroll region + self.apps_canvas.configure(scrollregion=self.apps_canvas.bbox("all")) + + # Check if scrolling is needed + canvas_height = self.apps_canvas.winfo_height() + content_height = self.apps_container.winfo_reqheight() + + # If content fits, reset scroll to top + if content_height <= canvas_height: + self.apps_canvas.yview_moveto(0) + + def _on_canvas_configure(self, event): + """When canvas is resized, adjust the container width to match canvas""" + canvas_width = event.width + # Only set width, let height be determined by content + self.apps_canvas.itemconfig(self.canvas_window, width=canvas_width) + + def _on_mousewheel(self, event): + """Handle mouse wheel scrolling - only if content exceeds canvas""" + canvas_height = self.apps_canvas.winfo_height() + content_height = self.apps_container.winfo_reqheight() + + # Only scroll if content is larger than canvas + if content_height > canvas_height: + if event.num == 4 or event.delta > 0: + self.apps_canvas.yview_scroll(-1, "units") + elif event.num == 5 or event.delta < 0: + self.apps_canvas.yview_scroll(1, "units") + + def on_double_click(self, event): + """Handle double-click on application card""" + # Add small delay to ensure window is properly created + self.master.after(100, self.edit_application) + + def show_context_menu(self, event): + """Show context menu on right-click for grid cards""" + if not self.selected_apps: + return + + # Create context menu + context_menu = tk.Menu(self.master, tearoff=0) + context_menu.add_command(label="āœļø Edit", command=self.edit_application) + context_menu.add_command(label="šŸ—‘ļø Remove", command=self.remove_applications) + context_menu.add_separator() + context_menu.add_command(label="šŸ“Š View Statistics", command=self.show_statistics) + context_menu.add_command(label="šŸ“‹ Copy Path", command=self.copy_path) + context_menu.add_separator() + context_menu.add_command(label="šŸ“‚ Open File Location", command=self.open_file_location) + + # Display menu at cursor position + try: + context_menu.tk_popup(event.x_root, event.y_root) + finally: + context_menu.grab_release() + + def update_apps_listbox(self): + """Update the applications grid with current apps""" + print("\n=== UPDATE_APPS_GRID START ===") + + # Clear existing cards + for widget in self.apps_container.winfo_children(): + widget.destroy() + + self.selected_apps = [] + app_count = len(self.app_locker.config["applications"]) + print(f"Total applications: {app_count}") + + if app_count == 0: + # Show empty state + empty_label = ttk.Label( + self.apps_container, + text="No applications added yet.\nClick 'āž• Add' to get started!", + font=("TkDefaultFont", 12), + foreground='#888888', + justify='center' + ) + empty_label.pack(expand=True, pady=50) + else: + # Create grid of application cards + columns = 3 # Number of cards per row + + for index, app in enumerate(self.app_locker.config["applications"]): + print(f"\n App {index + 1}: {app['name']}") + print(f" Path: {app['path']}") + + # Get metadata + meta = self.get_app_metadata(app['name']) + unlock_count = meta.get('unlock_count', 0) + print(f" Unlock count: {unlock_count}") + + # Calculate grid position + row = index // columns + col = index % columns + + # Create application card + card = self.create_app_card(app, meta, index) + card.grid(row=row, column=col, padx=10, pady=10, sticky='nsew') + + # Configure grid weights for responsiveness + for col in range(columns): + self.apps_container.grid_columnconfigure(col, weight=1, minsize=200) + + # Update app count label + self.app_count_label.config(text=f"Applications: {app_count}") + print(f"\n=== UPDATE_APPS_GRID END (Total: {app_count}) ===\n") + self.update_config_display() + + def create_app_card(self, app, meta, index): + """Create a single application card with icon, name, and stats""" + # Main card frame with border + card_frame = tk.Frame( + self.apps_container, + bg='#2a2a2a', + relief=tk.RAISED, + borderwidth=1, + highlightthickness=2, + highlightbackground='#444444' + ) + + # Store app data + card_frame.app_name = app['name'] + card_frame.app_path = app['path'] + card_frame.app_index = index + card_frame.is_selected = False + + # Inner padding frame + inner_frame = tk.Frame(card_frame, bg='#2a2a2a') + inner_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + + # Icon section + icon_frame = tk.Frame(inner_frame, bg='#2a2a2a') + icon_frame.pack(pady=(0, 10)) + + # Try to get actual icon (returns PhotoImage or None) + photo = self.get_app_icon(app['path']) + if photo: + print(f" āœ“ Icon found and loaded") + try: + # Resize the cached PhotoImage + # Note: get_app_icon returns 48x48, we want 64x64 + # So we need to get the path and reload + icon_path = self.find_desktop_icon(app['path']) + if icon_path and os.path.exists(icon_path): + img = Image.open(icon_path) + img = img.resize((64, 64), Image.Resampling.LANCZOS) + photo = ImageTk.PhotoImage(img) + + icon_label = tk.Label(icon_frame, image=photo, bg='#2a2a2a') + icon_label.image = photo # Keep reference + icon_label.pack() + else: + raise Exception("Could not find icon file") + except Exception as e: + print(f" āœ— Error resizing icon: {e}") + # Fallback to emoji + icon_label = tk.Label( + icon_frame, + text="šŸ“¦", + font=("TkDefaultFont", 32), + bg='#2a2a2a' + ) + icon_label.pack() + else: + print(f" āœ— No icon found, using emoji fallback") + icon_label = tk.Label( + icon_frame, + text="šŸ“¦", + font=("TkDefaultFont", 32), + bg='#2a2a2a' + ) + icon_label.pack() + + # App name + name_label = tk.Label( + inner_frame, + text=app['name'], + font=("TkDefaultFont", 11, "bold"), + bg='#2a2a2a', + fg='#ffffff', + wraplength=180 + ) + name_label.pack(pady=(0, 5)) + + # Stats + unlock_count = meta.get('unlock_count', 0) + stats_label = tk.Label( + inner_frame, + text=f"šŸ”“ {unlock_count}Ɨ unlocked", + font=("TkDefaultFont", 9), + bg='#2a2a2a', + fg='#888888' + ) + stats_label.pack() + + # Bind events for interaction + def on_click(event): + self.toggle_card_selection(card_frame) + + def on_double_click(event): + self.edit_application_from_card(card_frame) + + def on_right_click(event): + self.show_card_context_menu(event, card_frame) + + # Bind to all widgets in card + for widget in [card_frame, inner_frame, icon_frame, icon_label, name_label, stats_label]: + widget.bind('', on_click) + widget.bind('', on_double_click) + widget.bind('', on_right_click) + + return card_frame + + def toggle_card_selection(self, card): + """Toggle selection state of a card""" + if card.is_selected: + # Deselect + card.configure(highlightbackground='#444444', highlightthickness=2) + card.is_selected = False + if card.app_name in self.selected_apps: + self.selected_apps.remove(card.app_name) + else: + # Select + card.configure(highlightbackground='#009E60', highlightthickness=3) + card.is_selected = True + if card.app_name not in self.selected_apps: + self.selected_apps.append(card.app_name) + + print(f"Selected apps: {self.selected_apps}") + + def edit_application_from_card(self, card): + """Edit application from card""" + # Ensure only this card is selected + self.selected_apps = [card.app_name] + self.edit_application() + + def show_card_context_menu(self, event, card): + """Show context menu for a card""" + # Select this card if not already selected + if not card.is_selected: + self.toggle_card_selection(card) + + # Show context menu at cursor position + self.show_context_menu(event) + + def remove_applications(self): + """Remove selected applications with confirmation""" + if not self.selected_apps: + self.show_message("Error", "Please select at least one application to remove.") + return + + app_names = self.selected_apps.copy() + count = len(app_names) + + # Show confirmation dialog + if count == 1: + message = f"Are you sure you want to remove '{app_names[0]}'?" + else: + message = f"Are you sure you want to remove {count} applications?\n\n" + "\n".join(f"• {name}" for name in app_names[:5]) + if count > 5: + message += f"\n... and {count - 5} more" + + response = messagebox.askyesno("Confirm Removal", message, icon='warning') + if not response: + return + + # Remove all selected applications + for app_name in app_names: + self.app_locker.remove_application(app_name) + # Remove metadata + if app_name in self.metadata: + del self.metadata[app_name] + + self.save_metadata() + self.update_apps_listbox() + + if count == 1: + self.show_message("Success", f"Application '{app_names[0]}' removed successfully.") + else: + self.show_message("Success", f"{count} applications removed successfully.") + + def edit_application(self): + """Edit both name and path of selected application""" + if not self.selected_apps: + self.show_message("Error", "Please select an application to edit.") + return + + if len(self.selected_apps) > 1: + self.show_message("Error", "Please select only one application to edit.") + return + + # Get the selected app name + old_name = self.selected_apps[0] + + # Find the app in config + app_index = None + old_path = None + for idx, app in enumerate(self.app_locker.config["applications"]): + if app["name"] == old_name: + app_index = idx + old_path = app["path"] + break + + if app_index is None: + self.show_message("Error", "Application not found in configuration.") + return + + # Create edit dialog + self.edit_application_dialog(old_name, old_path, app_index) + + def edit_application_dialog(self, old_name, old_path, app_index): + """Show dialog to edit application name and path""" + dialog = tk.Toplevel(self.master) + dialog.title("Edit Application") + dialog.attributes('-topmost', True) + + # Wait for dialog to be created and visible + dialog.update_idletasks() + + # Center the dialog + screen_width = dialog.winfo_screenwidth() + screen_height = dialog.winfo_screenheight() + x = (screen_width // 2) - 250 + y = (screen_height // 2) - 125 + dialog.geometry(f"550x300+{x}+{y}") + + # Now grab after dialog is visible + dialog.after(50, dialog.grab_set) + + # Main frame + main_frame = ttk.Frame(dialog, padding="20") + main_frame.pack(fill=tk.BOTH, expand=True) + + # Title + title_label = ttk.Label( + main_frame, + text="Edit Application", + font=("TkDefaultFont", 14, "bold") + ) + title_label.pack(pady=(0, 15)) + + # Name field + name_frame = ttk.Frame(main_frame) + name_frame.pack(fill=tk.X, pady=5) + + ttk.Label(name_frame, text="Name:", width=10).pack(side=tk.LEFT) + name_entry = ttk.Entry(name_frame, width=50) + name_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=5) + name_entry.insert(0, old_name) + + # Path field + path_frame = ttk.Frame(main_frame) + path_frame.pack(fill=tk.X, pady=5) + + ttk.Label(path_frame, text="Path:", width=10).pack(side=tk.LEFT) + + # Entry and browse button container + path_input_frame = ttk.Frame(path_frame) + path_input_frame.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=5) + + path_entry = ttk.Entry(path_input_frame) + path_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) + path_entry.insert(0, old_path) + + # Browse button + def browse_file(): + file_path = filedialog.askopenfilename( + title="Select Application", + initialdir="/usr/bin" if old_path.startswith("/usr/bin") else os.path.dirname(old_path) + ) + if file_path: + path_entry.delete(0, tk.END) + path_entry.insert(0, file_path) + + ttk.Button(path_input_frame, text="Browse", command=browse_file).pack(side=tk.LEFT, padx=(5, 0)) + + # Metadata info + meta = self.get_app_metadata(old_name) + info_frame = ttk.Frame(main_frame) + info_frame.pack(fill=tk.X, pady=10) + + added_time = format_timestamp(meta['added_timestamp']) + modified_time = format_timestamp(meta['modified_timestamp']) + unlock_count = meta.get('unlock_count', 0) + + info_text = f"Added: {added_time} | Modified: {modified_time} | Unlocked: {unlock_count} times" + ttk.Label(info_frame, text=info_text, foreground='#888888', font=("TkDefaultFont", 9)).pack() + + # Buttons + button_frame = ttk.Frame(main_frame) + button_frame.pack(pady=20) + + def save_changes(): + new_name = name_entry.get().strip() + new_path = path_entry.get().strip() + + if not new_name or not new_path: + self.show_message("Error", "Both name and path are required.") + return + + if not os.path.exists(new_path): + self.show_message("Error", f"Path does not exist: {new_path}") + return + + # Check if name already exists (excluding current app) + for idx, app in enumerate(self.app_locker.config["applications"]): + if idx != app_index and app["name"] == new_name: + self.show_message("Error", f"An application with name '{new_name}' already exists.") + return + + # Update the application + self.app_locker.config["applications"][app_index]["name"] = new_name + self.app_locker.config["applications"][app_index]["path"] = new_path + + # Update metadata + if old_name != new_name and old_name in self.metadata: + # Rename metadata entry + self.metadata[new_name] = self.metadata.pop(old_name) + + self.update_modified_timestamp(new_name) + + self.update_apps_listbox() + self.show_message("Success", f"Application updated successfully!") + dialog.destroy() + + def cancel(): + dialog.destroy() + + ttk.Button( + button_frame, + text="šŸ’¾ Save", + command=save_changes, + style="green.TButton" + ).pack(side=tk.LEFT, padx=5) + + ttk.Button( + button_frame, + text="āŒ Cancel", + command=cancel, + style="red.TButton" + ).pack(side=tk.LEFT, padx=5) + + # Focus on name entry + name_entry.focus_set() + name_entry.select_range(0, tk.END) + + def show_statistics(self): + """Show detailed statistics for selected application""" + if not self.selected_apps: + self.show_message("Error", "Please select an application to view statistics.") + return + + app_name = self.selected_apps[0] + meta = self.get_app_metadata(app_name) + + # Format statistics + added_time = format_timestamp(meta['added_timestamp']) + modified_time = format_timestamp(meta['modified_timestamp']) + unlock_count = meta.get('unlock_count', 0) + last_unlocked = meta.get('last_unlocked') + last_unlocked_str = format_timestamp(last_unlocked) if last_unlocked else "Never" + + stats_message = f"""Statistics for: {app_name} + +šŸ“… Added: {added_time} +āœļø Last Modified: {modified_time} +šŸ”“ Total Unlocks: {unlock_count} +ā° Last Unlocked: {last_unlocked_str}""" + + self.show_message("Application Statistics", stats_message) + + def copy_path(self): + """Copy selected application path to clipboard""" + if not self.selected_apps: + return + + app_name = self.selected_apps[0] + + # Find path in config + path = None + for app in self.app_locker.config["applications"]: + if app["name"] == app_name: + path = app["path"] + break + + if path: + self.master.clipboard_clear() + self.master.clipboard_append(path) + self.show_message("Copied", f"Path copied to clipboard:\n{path}") + + def open_file_location(self): + """Open file manager at the application's location""" + if not self.selected_apps: + return + + app_name = self.selected_apps[0] + + # Find path in config + path = None + for app in self.app_locker.config["applications"]: + if app["name"] == app_name: + path = app["path"] + break + + if path: + directory = os.path.dirname(path) + if os.path.exists(directory): + if self.is_linux: + os.system(f'xdg-open "{directory}" &') + else: + os.system(f'explorer "{directory}"') + else: + self.show_message("Error", f"Directory does not exist:\n{directory}") + + def select_all_apps(self): + """Select all application cards""" + for widget in self.apps_container.winfo_children(): + if hasattr(widget, 'app_name') and not widget.is_selected: + self.toggle_card_selection(widget) + return 'break' # Prevent default Ctrl+A behavior + + def deselect_all_apps(self): + """Deselect all application cards""" + for widget in self.apps_container.winfo_children(): + if hasattr(widget, 'app_name') and widget.is_selected: + self.toggle_card_selection(widget) + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d26267e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +cryptography +psutil +pillow +pystray +watchdog +tkinterdnd2 +ttkbootstrap +pygame +requests diff --git a/test_icon_loading.py b/test_icon_loading.py new file mode 100644 index 0000000..ffd1c6d --- /dev/null +++ b/test_icon_loading.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +""" +Test script to verify icon loading logic for FadCrypt applications +""" + +import os +import subprocess +from pathlib import Path + +def find_desktop_icon(app_path): + """Find icon from .desktop file""" + print(f"\n=== Finding desktop icon for: {app_path} ===") + + # Get the executable name + app_name = os.path.basename(app_path).lower() + print(f"App name: {app_name}") + + # Search locations for .desktop files + desktop_locations = [ + '/usr/share/applications', + '/usr/local/share/applications', + os.path.expanduser('~/.local/share/applications') + ] + + for location in desktop_locations: + if not os.path.exists(location): + print(f" Skip: {location} (doesn't exist)") + continue + + print(f" Checking: {location}") + + # Try exact match first + desktop_file = os.path.join(location, f"{app_name}.desktop") + if os.path.exists(desktop_file): + print(f" āœ“ Found: {desktop_file}") + icon = parse_desktop_file(desktop_file) + if icon: + return icon + + # Try searching all .desktop files + try: + for filename in os.listdir(location): + if not filename.endswith('.desktop'): + continue + + if app_name in filename.lower(): + desktop_file = os.path.join(location, filename) + print(f" ~ Partial match: {desktop_file}") + icon = parse_desktop_file(desktop_file) + if icon: + return icon + except Exception as e: + print(f" āœ— Error reading {location}: {e}") + + print(f" āœ— No .desktop file found") + return None + +def parse_desktop_file(desktop_file): + """Parse .desktop file to extract icon""" + print(f" Parsing: {desktop_file}") + try: + with open(desktop_file, 'r') as f: + for line in f: + if line.startswith('Icon='): + icon = line.split('=', 1)[1].strip() + print(f" Icon entry: {icon}") + return icon + except Exception as e: + print(f" āœ— Error parsing: {e}") + return None + +def find_icon_by_name(icon_name): + """Search for icon file in standard directories""" + print(f"\n=== Finding icon file for: {icon_name} ===") + + icon_dirs = [ + '/usr/share/pixmaps', + '/usr/share/icons/hicolor/48x48/apps', + '/usr/share/icons/hicolor/32x32/apps', + '/usr/share/icons/hicolor/256x256/apps', + '/usr/share/icons/hicolor/scalable/apps', + '/usr/share/app-install/icons', + ] + + extensions = ['.png', '.svg', '.xpm'] + name_variants = [icon_name.lower(), icon_name.capitalize(), icon_name] + + for icon_dir in icon_dirs: + if not os.path.exists(icon_dir): + print(f" Skip: {icon_dir} (doesn't exist)") + continue + + print(f" Checking: {icon_dir}") + + for name_variant in name_variants: + for ext in extensions: + icon_path = os.path.join(icon_dir, f"{name_variant}{ext}") + if os.path.exists(icon_path): + print(f" āœ“ FOUND: {icon_path}") + return icon_path + + print(f" āœ— Icon file not found") + return None + +def test_icon_for_app(app_path): + """Test complete icon loading for an application""" + print(f"\n{'='*70}") + print(f"TESTING: {app_path}") + print(f"{'='*70}") + + # Check if app exists + if not os.path.exists(app_path): + print(f" āœ— Application does not exist!") + return None + + print(f" āœ“ Application exists") + + # Step 1: Find icon name from .desktop file + icon_name = find_desktop_icon(app_path) + + if not icon_name: + print(f"\n → No icon name found from .desktop file") + # Try using app name as fallback + icon_name = os.path.basename(app_path).lower() + print(f" → Using app basename as fallback: {icon_name}") + + # Step 2: Check if icon_name is already a full path + if icon_name.startswith('/') and os.path.exists(icon_name): + print(f"\n āœ“ Icon is full path and exists: {icon_name}") + return icon_name + + # Step 3: Find actual icon file + icon_path = find_icon_by_name(icon_name) + + if icon_path: + print(f"\n āœ“ SUCCESS: Icon found at {icon_path}") + return icon_path + else: + print(f"\n āœ— FAILED: No icon file found") + return None + +# Test with actual applications +test_apps = [ + '/usr/bin/brave-browser', + '/usr/bin/google-chrome', + '/usr/bin/firefox', +] + +print("=" * 70) +print("FADCRYPT ICON LOADING TEST") +print("=" * 70) + +results = {} +for app_path in test_apps: + icon_path = test_icon_for_app(app_path) + results[app_path] = icon_path + +# Summary +print("\n" + "=" * 70) +print("SUMMARY") +print("=" * 70) + +for app_path, icon_path in results.items(): + app_name = os.path.basename(app_path) + if icon_path: + print(f" āœ“ {app_name:20s} → {icon_path}") + else: + print(f" āœ— {app_name:20s} → NO ICON FOUND") + +print("=" * 70)