From 00d4fbc94da4d8fea9302cc95ec840facaa56552 Mon Sep 17 00:00:00 2001 From: Johann POLEWCZYK Date: Tue, 7 Jul 2026 22:30:40 +0200 Subject: [PATCH] Add recent history tracking --- aleappGUI.py | 314 +++++++++++++++++++++++++- assets/settings.png | Bin 0 -> 9440 bytes leapp_functions/__init__.py | 0 leapp_functions/app/__init__.py | 0 leapp_functions/app/history.py | 375 ++++++++++++++++++++++++++++++++ 5 files changed, 679 insertions(+), 10 deletions(-) create mode 100644 assets/settings.png create mode 100644 leapp_functions/__init__.py create mode 100644 leapp_functions/app/__init__.py create mode 100644 leapp_functions/app/history.py diff --git a/aleappGUI.py b/aleappGUI.py index bdeb808e..0d05eb0d 100755 --- a/aleappGUI.py +++ b/aleappGUI.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python3 + import tkinter as tk import typing import json @@ -6,6 +8,7 @@ import base64 import scripts.plugin_loader as plugin_loader +import leapp_functions.app.history as history from PIL import Image, ImageTk from tkinter import ttk, filedialog as tk_filedialog, messagebox as tk_msgbox @@ -20,6 +23,33 @@ open_output_folder, ) from scripts.context import Context +from scripts.lavafuncs import lava_json_name + + +def show_history_menu(button, path_type): + """ + Displays a popup menu with recently used paths. + """ + menu = tk.Menu(main_window, tearoff=0) + + if not history.is_history_enabled(): + menu.add_command(label="(History Disabled - Enable in Settings)", state="disabled") + else: + paths = history.get_input_paths() if path_type == 'input' else history.get_output_paths() + if not paths: + menu.add_command(label="(No recent paths)", state="disabled") + for path in paths: + def set_path(p=path): + if path_type == 'input': + input_entry.delete(0, tk.END) + input_entry.insert(0, p) + else: + output_entry.delete(0, tk.END) + output_entry.insert(0, p) + menu.add_command(label=history.format_path_for_display(path), command=set_path) + x = input_entry.winfo_rootx() + y = button.winfo_rooty() + button.winfo_height() + menu.post(x, y) def pickModules(): @@ -211,6 +241,178 @@ def open_website(url): webbrowser.open_new_tab(url) +def open_settings_window(): + '''Open Settings modal window''' + settings_window = tk.Toplevel(main_window) + settings_window.transient(main_window) + settings_window_width = 400 + settings_window_height = 260 + + #### Places Case Data window in the center of the main window + main_window.update_idletasks() + main_x = main_window.winfo_x() + main_y = main_window.winfo_y() + main_w = main_window.winfo_width() + main_h = main_window.winfo_height() + + margin_width = main_x + (main_w - settings_window_width) // 2 + margin_height = main_y + (main_h - settings_window_height) // 2 + + settings_window.geometry(f'{settings_window_width}x{settings_window_height}+{margin_width}+{margin_height}') + settings_window.resizable(False, False) + settings_window.configure(bg=theme_bgcolor) + settings_window.title('Settings') + settings_window.grid_columnconfigure(0, weight=1) + + def on_main_focus(event): + if settings_window.winfo_exists(): + settings_window.bell() + settings_window.lift() + settings_window.focus_force() + + main_window.bind("", on_main_focus) + + def close_settings_window(): + main_window.unbind("") + settings_window.destroy() + + settings_window.protocol("WM_DELETE_WINDOW", close_settings_window) + + settings_title_label = ttk.Label(settings_window, text='Settings', font='Helvetica 18') + settings_title_label.grid(row=0, column=0, padx=14, pady=7, sticky='w') + + history_enabled_var = tk.BooleanVar(value=history.is_history_enabled()) + + def toggle_history(): + history.set_history_enabled(history_enabled_var.get()) + + def update_clear_history_button(): + state = tk.NORMAL if history.has_history() else tk.DISABLED + clear_history_btn.config(state=state) + + def open_clear_history_window(): + clear_window = tk.Toplevel(settings_window) + clear_window.transient(settings_window) + clear_window_width = 460 + clear_window_height = 220 + + settings_window.update_idletasks() + settings_x = settings_window.winfo_x() + settings_y = settings_window.winfo_y() + settings_w = settings_window.winfo_width() + settings_h = settings_window.winfo_height() + + margin_width = settings_x + (settings_w - clear_window_width) // 2 + margin_height = settings_y + (settings_h - clear_window_height) // 2 + + clear_window.geometry(f'{clear_window_width}x{clear_window_height}+{margin_width}+{margin_height}') + clear_window.resizable(False, False) + clear_window.configure(bg=theme_bgcolor) + clear_window.title('Clear History') + clear_window.grid_columnconfigure(0, weight=1) + + def on_settings_focus(event): + if clear_window.winfo_exists(): + clear_window.bell() + clear_window.lift() + clear_window.focus_force() + + settings_window.bind("", on_settings_focus) + + def close_clear_window(): + settings_window.unbind("") + clear_window.destroy() + + clear_window.protocol("WM_DELETE_WINDOW", close_clear_window) + + clear_title_label = ttk.Label(clear_window, text='Clear History', font='Helvetica 18') + clear_title_label.grid(row=0, column=0, padx=14, pady=7, sticky='w') + + clear_message = ( + 'History is stored in shared LEAPP files. Input and output paths are shared by all ' + 'LEAPP tools, while recent runs are tagged by tool.' + ) + clear_message_label = ttk.Label(clear_window, text=clear_message, wraplength=420) + clear_message_label.grid(row=1, column=0, padx=14, pady=10, sticky='w') + + button_frame = ttk.Frame(clear_window) + button_frame.grid(row=2, column=0, padx=14, pady=18, sticky='e') + + def clear_single_leapp_history(): + if not tk_msgbox.askyesno( + title=f'Clear {leapp_name} history', + message=( + f'Clear shared recent input/output paths and ' + f'{leapp_name} recent run entries?' + ), + parent=clear_window): + return + history.clear_single_leapp_history(leapp_name.lower()) + update_clear_history_button() + close_clear_window() + + def clear_all_history(): + if not tk_msgbox.askyesno( + title='Clear all LEAPP history', + message='Clear all shared LEAPP history, including recent input and output paths?', + parent=clear_window): + return + history.clear_history() + update_clear_history_button() + close_clear_window() + + clear_single_leapp_btn = ttk.Button( + button_frame, + text=f'Clear {leapp_name} History', + command=clear_single_leapp_history) + clear_single_leapp_btn.pack(side='left', padx=5) + if not history.has_single_leapp_history(leapp_name.lower()): + clear_single_leapp_btn.config(state=tk.DISABLED) + + clear_all_btn = ttk.Button( + button_frame, + text='Clear All History', + command=clear_all_history) + clear_all_btn.pack(side='left', padx=5) + + cancel_btn = ttk.Button(button_frame, text='Cancel', command=close_clear_window) + cancel_btn.pack(side='left', padx=5) + + if is_platform_macos(): + clear_window.grab_set_global() + else: + clear_window.grab_set() + + history_check = tk.Checkbutton( + settings_window, + text='Enable saving paths as recent history', + variable=history_enabled_var, + command=toggle_history, + background=theme_bgcolor, + fg=theme_fgcolor, + selectcolor=theme_inputcolor, + activebackground=theme_bgcolor, + activeforeground=theme_fgcolor, + highlightthickness=0, + ) + history_check.grid(row=1, column=0, padx=14, pady=20, sticky='w') + + clear_history_btn = ttk.Button( + settings_window, + text='Clear History', + command=open_clear_history_window) + clear_history_btn.grid(row=2, column=0, padx=14, pady=10, sticky='w') + update_clear_history_button() + + close_btn = ttk.Button(settings_window, text='Close', command=close_settings_window) + close_btn.grid(row=3, column=0, padx=14, pady=20, sticky='e') + + if is_platform_macos(): + settings_window.grab_set_global() + else: + settings_window.grab_set() + + def resource_path(filename): try: base_path = sys._MEIPASS @@ -219,6 +421,7 @@ def resource_path(filename): return os.path.join(base_path, 'assets', filename) + def process(casedata): '''Execute selected modules and create reports''' # check if selections made properly; if not we will return to input form without exiting app altogether @@ -254,6 +457,10 @@ def process(casedata): initialize_lava(input_path, out_params.output_folder_base, extracttype) + # Record history if enabled + history.record_input_path(input_path) + history.record_output_path(output_folder) + crunch_successful = aleapp.crunch_artifacts( selected_modules, extracttype, input_path, out_params, wrap_text, loader, casedata, profile_filename) @@ -261,8 +468,11 @@ def process(casedata): lava_finalize_output(out_params.output_folder_base) if crunch_successful: + # Record the run in history report_path = os.path.join(out_params.output_folder_base, 'index.html') lava_project_path = os.path.join(out_params.output_folder_base, lava_json_name) + history.record_recent_run(leapp_name.lower(), leapp_version, lava_project_path) + output_folder_path = out_params.output_folder_base if report_path.startswith('\\\\?\\'): # windows report_path = report_path[4:] @@ -321,6 +531,8 @@ def select_input(button_type): input_filename = tk_filedialog.askdirectory(parent=main_window, title='Select a folder') input_entry.delete(0, 'end') input_entry.insert(0, input_filename) + if input_filename: + history.record_input_path(input_filename) def select_output(): @@ -328,6 +540,8 @@ def select_output(): output_filename = tk_filedialog.askdirectory(parent=main_window, title='Select a folder') output_entry.delete(0, 'end') output_entry.insert(0, output_filename) + if output_filename: + history.record_output_path(output_filename) def case_data(): @@ -428,25 +642,48 @@ def add_agency_logo(): ### Case Data Window creation case_window = tk.Toplevel(main_window) + case_window.transient(main_window) case_window_width = 560 if is_platform_linux(): case_window_height = 325 + elif is_platform_macos(): + case_window_height = 317 else: case_window_height = 305 - #### Places Case Data window in the center of the screen - screen_width = main_window.winfo_screenwidth() - screen_height = main_window.winfo_screenheight() - margin_width = (screen_width - case_window_width) // 2 - margin_height = (screen_height - case_window_height) // 2 + #### Places Case Data window in the center of the main window + main_window.update_idletasks() + main_x = main_window.winfo_x() + main_y = main_window.winfo_y() + main_w = main_window.winfo_width() + main_h = main_window.winfo_height() + + margin_width = main_x + (main_w - case_window_width) // 2 + margin_height = main_y + (main_h - case_window_height) // 2 #### Case Data window properties - case_window.geometry(f'{case_window_width}x{case_window_height}+{margin_width}+{margin_height}') + case_window.geometry( + f'{case_window_width}x{case_window_height}' + f'{geometry_offset(margin_width)}{geometry_offset(margin_height)}') case_window.resizable(False, False) case_window.configure(bg=theme_bgcolor) case_window.title('Add Case Data') case_window.grid_columnconfigure(0, weight=1) + def on_main_focus(event): + if case_window.winfo_exists(): + case_window.bell() + case_window.lift() + case_window.focus_force() + + main_window.bind("", on_main_focus) + + def close_case_window(): + main_window.unbind("") + case_window.destroy() + + case_window.protocol("WM_DELETE_WINDOW", close_case_window) + #### Layout case_title_label = ttk.Label(case_window, text='Add Case Data', font=('Helvetica 18')) case_title_label.grid(row=0, column=0, padx=14, pady=7, sticky='w') @@ -487,7 +724,10 @@ def add_agency_logo(): close_case_button = ttk.Button(modules_btn_frame, text='Close', command=case_window.destroy) close_case_button.grid(row=0, column=4, padx=5) - case_window.grab_set() + if is_platform_macos(): + case_window.grab_set_global() + else: + case_window.grab_set() ## Main window creation @@ -548,9 +788,15 @@ def add_agency_logo(): ### Top part of the window title_frame = ttk.Frame(main_window) title_frame.pack(padx=14, pady=8, fill='x') -ileapp_logo = ImageTk.PhotoImage(file=resource_path("ALEAPP_logo.png")) -ileapp_logo_label = ttk.Label(title_frame, image=ileapp_logo) -ileapp_logo_label.pack(side='left') +aleapp_logo = ImageTk.PhotoImage(file=resource_path("ALEAPP_logo.png")) +aleapp_logo_label = ttk.Label(title_frame, image=aleapp_logo) +aleapp_logo_label.pack(side='left') + +settings_icon = ImageTk.PhotoImage(Image.open(resource_path("settings.png")).resize((32, 32))) +settings_label = ttk.Label(title_frame, image=settings_icon, cursor="hand2") +settings_label.pack(side='right', padx=(10, 0)) +settings_label.bind("", lambda e: open_settings_window()) + leapps_logo = ImageTk.PhotoImage(Image.open(resource_path("leapps_a_logo.png")).resize((110, 51))) leapps_logo_label = ttk.Label(title_frame, image=leapps_logo, cursor="target") leapps_logo_label.pack(side='right') @@ -563,6 +809,9 @@ def add_agency_logo(): input_frame.pack(padx=14, pady=2, fill='x') input_entry = ttk.Entry(input_frame) input_entry.pack(side='left', padx=5, pady=4, fill='x', expand=True) +input_history_button = ttk.Button(input_frame, text='▼', width=3, + command=lambda: show_history_menu(input_history_button, 'input')) +input_history_button.pack(side='left', padx=2, pady=4) input_file_button = ttk.Button(input_frame, text='Browse File', command=lambda: select_input('file')) input_file_button.pack(side='left', padx=5, pady=4) input_folder_button = ttk.Button(input_frame, text='Browse Folder', command=lambda: select_input('folder')) @@ -572,6 +821,9 @@ def add_agency_logo(): output_frame.pack(padx=14, pady=5, fill='x') output_entry = ttk.Entry(output_frame) output_entry.pack(side='left', padx=5, pady=4, fill='x', expand=True) +output_history_button = ttk.Button(output_frame, text='▼', width=3, + command=lambda: show_history_menu(output_history_button, 'output')) +output_history_button.pack(side='left', padx=2, pady=4) output_folder_button = ttk.Button(output_frame, text='Browse Folder', command=select_output) output_folder_button.pack(side='left', padx=5, pady=4) @@ -658,8 +910,50 @@ def OnFocusIn(event): if type(event.widget).__name__ == 'Tk': event.widget.attributes('-topmost', False) +def geometry_offset(value): + return f'+{value}' if value >= 0 else str(value) + +def center_main_window_macos(window, width, height): + """ + MacOS: Determine full desktop dimensions and which monitor the mouse is on, + then center the window within those specific monitor boundaries. + """ + window.update_idletasks() + mx, my = window.winfo_pointerxy() + + # 1. Get primary monitor dimensions (always starts at 0,0 on macOS) + window.geometry("+0+0") + window.update_idletasks() + pw = window.winfo_screenwidth() + ph = window.winfo_screenheight() + + # 2. Move to mouse position to get the second monitor's dimensions + window.geometry(f'{geometry_offset(mx)}{geometry_offset(my)}') + window.update_idletasks() + sw = window.winfo_screenwidth() + sh = window.winfo_screenheight() + + # 3. Get virtual desktop bounds to handle monitors to the left/right + vx = window.winfo_vrootx() + + # 4. Determine the actual boundaries of the monitor the mouse is on + if mx < 0: # Monitor is to the left of primary + mon_x, mon_y, mon_w, mon_h = vx, 0, abs(vx), sh + elif mx >= pw: # Monitor is to the right of primary + mon_x, mon_y, mon_w, mon_h = pw, 0, sw, sh + else: # Mouse is on the primary monitor + mon_x, mon_y, mon_w, mon_h = 0, 0, pw, ph + + # 5. Center the window within those specific monitor boundaries + start_x = mon_x + (mon_w - width) // 2 + start_y = mon_y + (mon_h - height) // 2 + + window.geometry(f'{width}x{height}{geometry_offset(start_x)}{geometry_offset(start_y)}') + main_window.attributes('-topmost', True) main_window.focus_force() main_window.bind('', OnFocusIn) +if is_platform_macos(): + center_main_window_macos(main_window, 890, 690) main_window.mainloop() diff --git a/assets/settings.png b/assets/settings.png new file mode 100644 index 0000000000000000000000000000000000000000..59ed22030a778eeae652b972db504368d4fc88ab GIT binary patch literal 9440 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 2 and path[1] == ':' + + +def _with_windows_extended_prefix(path): + """ + Adds the Windows extended-length prefix for drive-letter paths. + """ + path = _path_history_key(path) + if platform.system() == "Windows" and _is_windows_drive_path(path): + return _WINDOWS_EXTENDED_PREFIX + path + return path + + +def _normalize_stored_path(path, prefer_long_path=False): + """ + Normalizes a path for storage in history. + """ + canonical = _path_history_key(path) + if prefer_long_path: + return _with_windows_extended_prefix(canonical) + return canonical + + +def format_path_for_display(path): + """ + Returns a user-friendly path string for display in menus and UI. + """ + return _strip_windows_extended_prefix(path) + + +def get_shared_directory(): + """ + Returns the OS-specific shared directory for LEAPP. + Windows: %APPDATA%\\LEAPP + macOS: ~/Library/Application Support/LEAPP + Linux: ~/.config/LEAPP (or $XDG_CONFIG_HOME/LEAPP) + """ + home = Path.home() + system = platform.system() + + if system == "Windows": + appdata = os.environ.get("APPDATA") + if appdata: + return Path(appdata) / "LEAPP" + return home / "AppData" / "Roaming" / "LEAPP" + elif system == "Darwin": + return home / "Library" / "Application Support" / "LEAPP" + else: # Linux and others + xdg_config = os.environ.get("XDG_CONFIG_HOME") + if xdg_config: + return Path(xdg_config) / "LEAPP" + return home / ".config" / "LEAPP" + + +def get_settings_path(): + """ + Returns the path to the settings.json file. + """ + return get_shared_directory() / "settings.json" + + +def get_history_path(): + """ + Returns the path to the history.json file. + """ + return get_shared_directory() / "history.json" + + +def _atomic_write_json(path, data): + """ + Writes data to a temporary file and then renames it to the destination path. + """ + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + temp_path = path.with_suffix(".tmp") + + try: + with open(temp_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + f.flush() + os.fsync(f.fileno()) + + # On Windows, os.replace might fail if the destination exists and is open + # but for our small JSON files it should generally be fine. + os.replace(temp_path, path) + except OSError as e: + logger.error("Failed to write history/settings file at %s: %s", path, e) + if temp_path.exists(): + try: + temp_path.unlink() + except OSError: + pass + + +def _read_json(path, default=None): + """ + Reads a JSON file and returns the data. + """ + if default is None: + default = {} + if not os.path.exists(path): + return default + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except OSError as e: + logger.error("Failed to read history/settings file at %s: %s", path, e) + return default + + +def _has_history_entries(history_data): + return any( + history_data.get(key) + for key in ("input_paths", "output_paths", "runs") + ) + + +def is_history_enabled(): + """ + Checks if history recording is enabled in settings. + """ + settings = _read_json(get_settings_path()) + return settings.get("history_enabled", False) + + +def set_history_enabled(enabled): + """ + Enables or disables history recording in settings. + """ + settings = _read_json(get_settings_path()) + settings["schema_version"] = SCHEMA_VERSION + settings["history_enabled"] = enabled + if "path_history_limit" not in settings: + settings["path_history_limit"] = 10 + if "run_history_limit" not in settings: + settings["run_history_limit"] = 20 + _atomic_write_json(get_settings_path(), settings) + + +def _get_limit(key, default): + """ + Returns a limit value from settings. + """ + settings = _read_json(get_settings_path()) + return settings.get(key, default) + + +def get_input_paths(): + """ + Returns the list of recently used input paths. + """ + if not is_history_enabled(): + return [] + history_data = _read_json(get_history_path()) + return history_data.get("input_paths", []) + + +def record_input_path(path): + """ + Records a new input path in history. + """ + if not is_history_enabled(): + return + prefer_long_path = os.path.isdir(_strip_windows_extended_prefix(path)) + _record_path("input_paths", path, _get_limit("path_history_limit", 10), prefer_long_path) + + +def get_output_paths(): + """ + Returns the list of recently used output paths. + """ + if not is_history_enabled(): + return [] + history_data = _read_json(get_history_path()) + return history_data.get("output_paths", []) + + +def record_output_path(path): + """ + Records a new output path in history. + """ + if not is_history_enabled(): + return + _record_path("output_paths", path, _get_limit("path_history_limit", 10), prefer_long_path=True) + + +def _record_path(key, path, limit, prefer_long_path=False): + """ + Internal helper to record a path in history. + """ + if not path: + return + stored_path = _normalize_stored_path(path, prefer_long_path) + path_key = _path_history_key(path) + history_data = _read_json(get_history_path()) + history_data["schema_version"] = SCHEMA_VERSION + paths = history_data.get(key, []) + + # Deduplicate by canonical path and move to top + paths = [p for p in paths if _path_history_key(p) != path_key] + paths.insert(0, stored_path) + + # Limit + history_data[key] = paths[:limit] + _atomic_write_json(get_history_path(), history_data) + + +def get_recent_runs(): + """ + Returns the list of recent tool runs. + """ + if not is_history_enabled(): + return [] + history_data = _read_json(get_history_path()) + return history_data.get("runs", []) + + +def has_history(): + """ + Returns True if the shared history file has any stored entries. + """ + history_data = _read_json(get_history_path()) + return _has_history_entries(history_data) + + +def has_single_leapp_history(leapp_id): + """ + Returns True if shared paths or this LEAPP tool's runs exist. + """ + history_data = _read_json(get_history_path()) + if history_data.get("input_paths") or history_data.get("output_paths"): + return True + + leapp_id = leapp_id.lower() + return any( + run.get("leapp_id") == leapp_id + for run in history_data.get("runs", []) + ) + + +def record_recent_run(leapp_id, leapp_version, lava_file_path): + """ + Records a successful tool run in history. + """ + if not is_history_enabled(): + return + if not lava_file_path or not os.path.exists(lava_file_path): + return + + lava_file_path = _normalize_stored_path(lava_file_path, prefer_long_path=True) + path_key = _path_history_key(lava_file_path) + history_data = _read_json(get_history_path()) + history_data["schema_version"] = SCHEMA_VERSION + runs = history_data.get("runs", []) + + # Deduplicate by canonical lava file path + runs = [r for r in runs if _path_history_key(r.get("lava_file_path", "")) != path_key] + + new_run = { + "leapp_id": leapp_id.lower(), + "leapp_version": leapp_version, + "recorded_at": datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"), + "lava_file_path": lava_file_path + } + runs.insert(0, new_run) + + # Limit + limit = _get_limit("run_history_limit", 20) + history_data["runs"] = runs[:limit] + _atomic_write_json(get_history_path(), history_data) + + +def remove_recent_run(lava_file_path): + """ + Removes a specific run from history. + """ + if not is_history_enabled(): + return + lava_file_path = _normalize_stored_path(lava_file_path, prefer_long_path=True) + path_key = _path_history_key(lava_file_path) + history_data = _read_json(get_history_path()) + runs = history_data.get("runs", []) + new_runs = [r for r in runs if _path_history_key(r.get("lava_file_path", "")) != path_key] + if len(runs) != len(new_runs): + history_data["runs"] = new_runs + _atomic_write_json(get_history_path(), history_data) + + +def clear_single_leapp_history(leapp_id): + """ + Clears shared paths and this LEAPP tool's recent runs. + """ + history_data = _read_json(get_history_path()) + history_data.pop("input_paths", None) + history_data.pop("output_paths", None) + + leapp_id = leapp_id.lower() + runs = history_data.get("runs", []) + history_data["runs"] = [ + run for run in runs + if run.get("leapp_id") != leapp_id + ] + + if _has_history_entries(history_data): + _atomic_write_json(get_history_path(), history_data) + else: + clear_history() + + +def clear_history(): + """ + Deletes the history file. + """ + if os.path.exists(get_history_path()): + try: + os.remove(get_history_path()) + except OSError as e: + logger.error("Failed to clear history: %s", e)