335 lines
13 KiB
Python
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()
|