SXXXXXXX_CppPythonDebug/dependency_bundler_gui.py
2025-06-09 10:52:43 +02:00

335 lines
13 KiB
Python

# File: dependency_bundler_gui.py
# A simple GUI tool to bundle an executable with DLLs from its directory.
import tkinter as tk
from tkinter import ttk, filedialog, scrolledtext, messagebox
import os
import shutil
import logging
import threading
from typing import Optional, List, Set
# Basic Logging Setup for the bundler tool itself
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s [%(levelname)-7s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("DependencyBundler")
class DependencyBundlerApp(tk.Tk):
def __init__(self):
super().__init__()
self.title("Dependency Bundler Tool")
self.geometry("750x550")
self.minsize(600, 400)
self.target_exe_path_var = tk.StringVar()
self.output_dir_path_var = tk.StringVar()
# Optional: Extensions to look for in the source directory besides .dll
self.additional_extensions_var = tk.StringVar(value=".exe")
self._create_widgets()
self.protocol("WM_DELETE_WINDOW", self._on_closing)
def _create_widgets(self):
main_frame = ttk.Frame(self, padding="10")
main_frame.pack(expand=True, fill=tk.BOTH)
# Configure grid weights for resizing
main_frame.columnconfigure(1, weight=1)
main_frame.rowconfigure(3, weight=1) # Log area will expand
# --- Input Path Frame ---
input_frame = ttk.LabelFrame(main_frame, text="Paths", padding="10")
input_frame.grid(row=0, column=0, columnspan=3, sticky="ew", padx=5, pady=5)
input_frame.columnconfigure(1, weight=1)
ttk.Label(input_frame, text="Target Executable:").grid(
row=0, column=0, sticky=tk.W, padx=5, pady=3
)
ttk.Entry(input_frame, textvariable=self.target_exe_path_var).grid(
row=0, column=1, sticky="ew", padx=5, pady=3
)
ttk.Button(input_frame, text="Browse...", command=self._browse_target_exe).grid(
row=0, column=2, padx=5, pady=3
)
ttk.Label(input_frame, text="Output Bundle Directory:").grid(
row=1, column=0, sticky=tk.W, padx=5, pady=3
)
ttk.Entry(input_frame, textvariable=self.output_dir_path_var).grid(
row=1, column=1, sticky="ew", padx=5, pady=3
)
ttk.Button(input_frame, text="Browse...", command=self._browse_output_dir).grid(
row=1, column=2, padx=5, pady=3
)
# --- Options Frame (Optional) ---
options_frame = ttk.LabelFrame(main_frame, text="Options", padding="10")
options_frame.grid(row=1, column=0, columnspan=3, sticky="ew", padx=5, pady=5)
options_frame.columnconfigure(1, weight=1)
ttk.Label(
options_frame,
text="Copy files with these extensions (comma-separated, e.g., .exe,.dll,.manifest):",
).grid(row=0, column=0, sticky=tk.W, padx=5, pady=3)
ttk.Entry(options_frame, textvariable=self.additional_extensions_var).grid(
row=0, column=1, columnspan=2, sticky="ew", padx=5, pady=3
)
# --- Action Button ---
self.start_button = ttk.Button(
main_frame, text="Start Bundling", command=self._start_bundling_process
)
self.start_button.grid(row=2, column=0, columnspan=3, pady=10)
# --- Log Area ---
log_frame = ttk.LabelFrame(main_frame, text="Log", padding="10")
log_frame.grid(row=3, column=0, columnspan=3, sticky="nsew", padx=5, pady=5)
log_frame.rowconfigure(0, weight=1)
log_frame.columnconfigure(0, weight=1)
self.log_text = scrolledtext.ScrolledText(
log_frame, wrap=tk.WORD, state=tk.DISABLED, height=10, font=("Consolas", 9)
)
self.log_text.grid(row=0, column=0, sticky="nsew")
# --- Status Bar ---
self.status_var = tk.StringVar(value="Ready.")
status_bar = ttk.Label(
main_frame, textvariable=self.status_var, relief=tk.SUNKEN, anchor=tk.W
)
status_bar.grid(row=4, column=0, columnspan=3, sticky="ew", ipady=2)
def _log_message(self, message: str, level: str = "INFO"):
if not self.log_text.winfo_exists():
return
formatted_message = f"[{level.upper()}] {message}"
self.log_text.config(state=tk.NORMAL)
self.log_text.insert(tk.END, formatted_message + "\n")
self.log_text.see(tk.END)
self.log_text.config(state=tk.DISABLED)
if level.upper() == "INFO":
logger.info(message)
elif level.upper() == "WARNING":
logger.warning(message)
elif level.upper() == "ERROR":
logger.error(message)
else:
logger.debug(message) # Default to debug for other levels
self.update_idletasks()
def _update_status(self, message: str):
self.status_var.set(message)
self._log_message(message, "STATUS")
self.update_idletasks()
def _browse_target_exe(self):
# Consider initialdir based on current value if any
initial_dir = (
os.path.dirname(self.target_exe_path_var.get())
if self.target_exe_path_var.get()
else None
)
filepath = filedialog.askopenfilename(
title="Select Target Executable",
filetypes=(("Executable files", "*.exe"), ("All files", "*.*")),
initialdir=initial_dir,
parent=self,
)
if filepath:
self.target_exe_path_var.set(filepath)
self._log_message(f"Target executable set to: {filepath}")
# Auto-suggest output directory based on target
if not self.output_dir_path_var.get():
base_name = os.path.splitext(os.path.basename(filepath))[0]
suggested_output_dir = os.path.join(
os.path.dirname(filepath), f"{base_name}_bundle"
)
self.output_dir_path_var.set(suggested_output_dir)
self._log_message(f"Suggested output directory: {suggested_output_dir}")
def _browse_output_dir(self):
initial_dir = (
self.output_dir_path_var.get() if self.output_dir_path_var.get() else None
)
dirpath = filedialog.askdirectory(
title="Select Output Bundle Directory", initialdir=initial_dir, parent=self
)
if dirpath:
self.output_dir_path_var.set(dirpath)
self._log_message(f"Output directory set to: {dirpath}")
def _validate_inputs(self) -> bool:
target_exe = self.target_exe_path_var.get()
output_dir = self.output_dir_path_var.get()
if not target_exe:
messagebox.showerror(
"Input Error", "Target Executable path cannot be empty.", parent=self
)
return False
if not os.path.isfile(target_exe):
messagebox.showerror(
"Input Error", f"Target Executable not found: {target_exe}", parent=self
)
return False
if not output_dir:
messagebox.showerror(
"Input Error", "Output Bundle Directory cannot be empty.", parent=self
)
return False
extensions_str = self.additional_extensions_var.get().strip()
if extensions_str:
for ext in extensions_str.split(","):
if not ext.strip().startswith("."):
messagebox.showerror(
"Input Error",
f"Invalid extension format: '{ext.strip()}'. "
"Extensions should start with a dot (e.g., .dll, .manifest).",
parent=self,
)
return False
return True
def _start_bundling_process(self):
if not self._validate_inputs():
return
self.start_button.config(state=tk.DISABLED)
self._update_status("Starting bundling process...")
self.log_text.config(state=tk.NORMAL)
self.log_text.delete("1.0", tk.END) # Clear previous log
self.log_text.config(state=tk.DISABLED)
target_exe = self.target_exe_path_var.get()
output_dir = self.output_dir_path_var.get()
extensions_to_copy_str = self.additional_extensions_var.get().strip().lower()
# Always include .dll, then add others from the input field
extensions_set: Set[str] = {".dll"}
if extensions_to_copy_str:
for ext_item in extensions_to_copy_str.split(","):
clean_ext = ext_item.strip()
if clean_ext: # Ensure it's not empty after strip
extensions_set.add(clean_ext)
# Convert set to list for processing if needed, though set is fine for `in` checks
final_extensions_list = list(extensions_set)
self._log_message(
f"Will copy executable and files with extensions: {', '.join(sorted(final_extensions_list))}"
)
# Run the bundling in a separate thread to keep GUI responsive
bundling_thread = threading.Thread(
target=self._bundle_dependencies_thread_target,
args=(
target_exe,
output_dir,
final_extensions_list,
), # Pass the list of extensions
daemon=True,
)
bundling_thread.start()
def _bundle_dependencies_thread_target(
self,
target_exe_path: str,
output_bundle_dir: str,
file_extensions_to_copy: List[str],
):
try:
self._update_status("Preparing output directory...")
if not os.path.exists(output_bundle_dir):
os.makedirs(output_bundle_dir)
self._log_message(f"Created output directory: {output_bundle_dir}")
else:
self._log_message(
f"Output directory already exists: {output_bundle_dir}"
)
source_exe_dir = os.path.dirname(target_exe_path)
target_exe_filename = os.path.basename(target_exe_path)
# 1. Copy the target executable itself
dest_exe_path = os.path.join(output_bundle_dir, target_exe_filename)
self._log_message(
f"Copying '{target_exe_filename}' to '{output_bundle_dir}'..."
)
shutil.copy2(target_exe_path, dest_exe_path)
self._log_message(f"Copied: {target_exe_filename}")
# 2. Copy other specified files (DLLs and additional extensions) from the source directory
copied_count = 0
found_files_in_source_dir = os.listdir(source_exe_dir)
self._update_status(
f"Scanning {len(found_files_in_source_dir)} files in '{source_exe_dir}'..."
)
for filename in found_files_in_source_dir:
file_path_in_source = os.path.join(source_exe_dir, filename)
# Check if it's a file and has one of the desired extensions
if os.path.isfile(file_path_in_source):
file_ext_lower = os.path.splitext(filename)[1].lower()
if file_ext_lower in file_extensions_to_copy:
# Avoid re-copying the main executable if it matches an extension (e.g. .exe)
if os.path.normpath(file_path_in_source) == os.path.normpath(
target_exe_path
) and target_exe_filename.lower().endswith(
file_ext_lower
): # Make sure it's actually the exe
self._log_message(
f"Skipping main executable '{filename}' (already copied).",
"DEBUG",
)
continue
dest_path = os.path.join(output_bundle_dir, filename)
self._log_message(
f"Copying '{filename}' to '{output_bundle_dir}'..."
)
shutil.copy2(file_path_in_source, dest_path)
self._log_message(f"Copied: {filename}")
copied_count += 1
self._log_message(f"Copied {copied_count} additional dependency files.")
final_message = f"Bundling complete! Output: {output_bundle_dir}"
self._update_status(final_message)
if self.winfo_exists():
self.after(
0,
lambda: messagebox.showinfo(
"Bundling Complete", final_message, parent=self
),
)
except Exception as e:
error_msg = f"Error during bundling: {e}"
self._log_message(error_msg, "ERROR")
self._update_status(f"Bundling failed. See log. Error: {e}")
logger.error(error_msg, exc_info=True)
if self.winfo_exists():
self.after(
0,
lambda: messagebox.showerror(
"Bundling Error", error_msg, parent=self
),
)
finally:
if self.winfo_exists():
self.after(0, lambda: self.start_button.config(state=tk.NORMAL))
def _on_closing(self):
logger.info("Dependency Bundler GUI closing.")
self.destroy()
if __name__ == "__main__":
app = DependencyBundlerApp()
app.mainloop()