Created
April 3, 2025 04:52
-
-
Save beardordie/3cbf656b40442dea59dbf0528026ad79 to your computer and use it in GitHub Desktop.
A program to view a list of files in a .unitypackage file, preview with spacebar, and (selectively) extract what you want, no need to import into a Unity project (made with Gemini Pro 2.5 Experimental)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# unitypackageutility.py | |
import tkinter as tk | |
from tkinter import ttk, filedialog, messagebox | |
import sys | |
import os | |
import tarfile | |
import io | |
import shutil | |
import tempfile | |
import subprocess | |
import time # Added for potential small delay in preview | |
from pathlib import Path | |
# Define the expected encoding for pathname files within the unitypackage | |
# Often UTF-8, but adjust if necessary based on package origin. | |
PATHNAME_ENCODING = 'utf-8' | |
# Path to Notepad++ executable (using absolute path) | |
NOTEPAD_PLUS_PLUS_CMD = r"C:\Program Files (x86)\Notepad++\notepad++.exe" | |
class UnityPackageViewer(tk.Tk): | |
def __init__(self, package_path): | |
super().__init__() | |
# --- Input Validation --- | |
if not package_path or not os.path.isfile(package_path): | |
# Show error before main window potentially fails | |
root = tk.Tk() | |
root.withdraw() | |
messagebox.showerror("Error", f"File not found or invalid:\n{package_path}") | |
root.destroy() | |
sys.exit(1) | |
try: | |
if not tarfile.is_tarfile(package_path): | |
# Show error before main window potentially fails | |
root = tk.Tk() | |
root.withdraw() | |
messagebox.showerror("Error", f"Not a valid TAR archive (or unitypackage):\n{package_path}") | |
root.destroy() | |
sys.exit(1) | |
except (FileNotFoundError, tarfile.ReadError, Exception) as e: | |
# Catch potential errors during is_tarfile check itself | |
root = tk.Tk() | |
root.withdraw() | |
messagebox.showerror("Error Opening File", f"Could not open or read the file:\n{package_path}\n\nError: {e}") | |
root.destroy() | |
sys.exit(1) | |
self.package_path = package_path | |
self.title(f"UnityPackage Contents - {os.path.basename(package_path)}") | |
self.geometry("700x500") # Slightly wider/taller for buttons | |
# Store mapping: display path -> actual tar member info for the 'asset' file | |
self.file_map = {} | |
# Store the temporary directory object for preview if needed, to manage its lifecycle | |
self._preview_temp_dir = None | |
# --- GUI Elements --- | |
main_frame = ttk.Frame(self, padding="10") | |
main_frame.pack(fill=tk.BOTH, expand=True) | |
# Top label | |
ttk.Label(main_frame, text="Files inside package:").pack(anchor=tk.W) | |
# --- Listbox Frame --- | |
list_frame = ttk.Frame(main_frame) | |
list_frame.pack(fill=tk.BOTH, expand=True, pady=(5, 10)) # Add padding below | |
self.scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL) | |
# Changed selectmode to EXTENDED for multi-select | |
self.listbox = tk.Listbox(list_frame, yscrollcommand=self.scrollbar.set, selectmode=tk.EXTENDED, exportselection=False) | |
self.scrollbar.config(command=self.listbox.yview) | |
self.scrollbar.pack(side=tk.RIGHT, fill=tk.Y) | |
self.listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) | |
# --- Button Frame --- | |
button_frame = ttk.Frame(main_frame) | |
button_frame.pack(fill=tk.X, side=tk.BOTTOM) | |
# Extract Selected Button (initially disabled) | |
self.extract_selected_button = ttk.Button( | |
button_frame, | |
text="Extract Selected File(s)", | |
command=self.on_extract_selected, | |
state=tk.DISABLED # Start disabled | |
) | |
self.extract_selected_button.pack(side=tk.LEFT, padx=(0, 5)) | |
# Extract All Button | |
self.extract_all_button = ttk.Button( | |
button_frame, | |
text="Extract All Files", | |
command=self.on_extract_all | |
) | |
self.extract_all_button.pack(side=tk.LEFT) | |
# --- Bind Events --- | |
self.listbox.bind("<Double-Button-1>", self.on_double_click_extract_single) # Keep double-click for single extract | |
self.listbox.bind("<<ListboxSelect>>", self.on_listbox_select) # Update button state on selection change | |
self.listbox.bind("<KeyPress-space>", self.on_spacebar_preview) # Handle spacebar press | |
self.bind("<Destroy>", self.on_destroy) # Cleanup temp dir on window close | |
# --- Load Contents --- | |
self.load_package_contents() | |
# --- Event Handlers --- | |
def on_listbox_select(self, event=None): | |
"""Updates the state of the 'Extract Selected' button based on listbox selection.""" | |
if self.listbox.curselection(): | |
self.extract_selected_button.config(state=tk.NORMAL) | |
else: | |
self.extract_selected_button.config(state=tk.DISABLED) | |
def on_double_click_extract_single(self, event): | |
"""Handles double-clicking on a listbox item to extract just that one.""" | |
selected_indices = self.listbox.curselection() | |
# Although double-click usually implies one item, check anyway | |
if len(selected_indices) != 1: | |
return | |
selected_index = selected_indices[0] | |
display_path = self.listbox.get(selected_index) | |
self._prompt_and_extract_single(display_path) | |
def on_spacebar_preview(self, event): | |
"""Handles spacebar press for previewing a single selected file.""" | |
selected_indices = self.listbox.curselection() | |
if len(selected_indices) != 1: | |
# Only preview if exactly one item is selected | |
return | |
selected_index = selected_indices[0] | |
display_path = self.listbox.get(selected_index) | |
if display_path in self.file_map: | |
asset_member_info = self.file_map[display_path] | |
if not asset_member_info.isfile(): | |
# Should not happen based on loading logic, but check | |
messagebox.showwarning("Preview Not Possible", f"'{display_path}'\nis not a file and cannot be previewed.") | |
return | |
# Clean up previous preview temp dir if it exists and wasn't handled | |
self._cleanup_preview_temp_dir() | |
try: | |
# Create a new temporary directory that persists until cleanup | |
self._preview_temp_dir = tempfile.TemporaryDirectory(prefix="unitypkg_preview_") | |
temp_dir_path = self._preview_temp_dir.name | |
# Extract the file into the temporary directory | |
# Use the original filename within the temp dir for association purposes | |
temp_file_path = os.path.join(temp_dir_path, os.path.basename(display_path)) | |
# Perform the actual extraction | |
success = self._extract_file_core(asset_member_info, temp_file_path, show_success_msg=False) | |
if success: | |
# Launch the file | |
self._launch_file(temp_file_path) | |
# Note: The temp dir (self._preview_temp_dir) remains until the next preview or window close | |
except PermissionError: | |
messagebox.showerror("Permission Error", f"Could not write temporary file for preview.\nCheck permissions for your temp folder.") | |
self._cleanup_preview_temp_dir() # Clean up if created but failed later | |
except Exception as e: | |
messagebox.showerror("Preview Failed", f"Could not preview file '{display_path}':\n{e}") | |
self._cleanup_preview_temp_dir() # Clean up on any error | |
else: | |
# This case should ideally not happen if listbox matches file_map | |
messagebox.showerror("Internal Error", "Could not find internal mapping for the selected item for preview.") | |
def on_extract_selected(self): | |
"""Handles the 'Extract Selected File(s)' button click.""" | |
selected_indices = self.listbox.curselection() | |
if not selected_indices: | |
messagebox.showwarning("No Selection", "Please select one or more files to extract.") | |
return | |
selected_paths = [self.listbox.get(i) for i in selected_indices] | |
output_folder = filedialog.askdirectory( | |
title="Select Folder to Extract Selected Files" | |
) | |
if not output_folder: # User cancelled | |
return | |
extracted_count = 0 | |
error_count = 0 | |
error_list = [] | |
for display_path in selected_paths: | |
if display_path in self.file_map: | |
asset_member_info = self.file_map[display_path] | |
if asset_member_info.isfile(): | |
# Construct output path maintaining relative structure | |
relative_path = display_path.lstrip('/') # Ensure no leading slash | |
output_path = os.path.join(output_folder, relative_path) | |
# Extract the file | |
success = self._extract_file_core(asset_member_info, output_path, show_success_msg=False) | |
if success: | |
extracted_count += 1 | |
else: | |
error_count += 1 | |
error_list.append(display_path) | |
# Silently skip non-files if they somehow get selected/mapped | |
else: | |
error_count += 1 # Should not happen, indicates map mismatch | |
error_list.append(f"{display_path} (mapping error)") | |
# --- Report Results --- | |
if error_count == 0: | |
messagebox.showinfo("Extraction Complete", f"Successfully extracted {extracted_count} selected file(s) to:\n'{output_folder}'") | |
else: | |
error_details = "\n - ".join(error_list[:10]) # Show first 10 errors | |
if len(error_list) > 10: | |
error_details += f"\n... and {len(error_list) - 10} more." | |
messagebox.showwarning("Extraction Partially Failed", | |
f"Successfully extracted: {extracted_count} file(s).\n" | |
f"Failed to extract: {error_count} file(s).\n\n" | |
f"Output folder:\n'{output_folder}'\n\n" | |
f"Failures:\n - {error_details}") | |
def on_extract_all(self): | |
"""Handles the 'Extract All Files' button click.""" | |
if not self.file_map: | |
messagebox.showinfo("Empty Package", "There are no extractable files in this package.") | |
return | |
output_folder = filedialog.askdirectory( | |
title="Select Folder to Extract All Files" | |
) | |
if not output_folder: # User cancelled | |
return | |
extracted_count = 0 | |
error_count = 0 | |
error_list = [] | |
total_files = len(self.file_map) # For potential progress updates later | |
# Iterate through all known files from the map | |
for display_path, asset_member_info in self.file_map.items(): | |
if asset_member_info.isfile(): | |
# Construct output path maintaining relative structure | |
relative_path = display_path.lstrip('/') # Ensure no leading slash | |
output_path = os.path.join(output_folder, relative_path) | |
# Extract the file | |
success = self._extract_file_core(asset_member_info, output_path, show_success_msg=False) | |
if success: | |
extracted_count += 1 | |
else: | |
error_count += 1 | |
error_list.append(display_path) | |
# --- Report Results --- | |
if error_count == 0: | |
messagebox.showinfo("Extraction Complete", f"Successfully extracted all {extracted_count} file(s) to:\n'{output_folder}'") | |
else: | |
error_details = "\n - ".join(error_list[:10]) # Show first 10 errors | |
if len(error_list) > 10: | |
error_details += f"\n... and {len(error_list) - 10} more." | |
messagebox.showwarning("Extraction Partially Failed", | |
f"Successfully extracted: {extracted_count} file(s).\n" | |
f"Failed to extract: {error_count} file(s).\n\n" | |
f"Output folder:\n'{output_folder}'\n\n" | |
f"Failures:\n - {error_details}") | |
def on_destroy(self, event=None): | |
"""Cleans up the temporary preview directory when the window is closed.""" | |
self._cleanup_preview_temp_dir() | |
# --- Core Logic Methods --- | |
def load_package_contents(self): | |
"""Reads the tar archive and populates the listbox.""" | |
self.listbox.delete(0, tk.END) # Clear existing items | |
self.file_map.clear() | |
pathnames = {} # Stores { guid: pathname_str } | |
asset_members = {} # Stores { guid: asset_tarinfo } | |
try: | |
with tarfile.open(self.package_path, 'r') as tar: | |
# First pass: Read all pathname files and identify asset members | |
for member in tar.getmembers(): | |
# Normalize path separators for reliable splitting | |
normalized_name = member.name.replace('\\', '/') | |
parts = normalized_name.split('/') | |
# Check for standard Unity package structure: GUID/asset or GUID/pathname | |
if len(parts) == 2: | |
guid = parts[0] | |
filename = parts[1] | |
if filename == 'pathname': | |
# Read the content of the pathname file | |
pathname_file = tar.extractfile(member) | |
if pathname_file: | |
try: | |
# Read content and decode, strip whitespace/newlines | |
pathname_str = pathname_file.read().decode(PATHNAME_ENCODING).strip() | |
# Normalize path separators in the pathname itself | |
pathname_str = pathname_str.replace('\\', '/') | |
pathnames[guid] = pathname_str | |
except Exception as e: | |
print(f"Warning: Could not read/decode pathname for {member.name}: {e}") | |
finally: | |
if pathname_file: pathname_file.close() | |
elif filename == 'asset': | |
# Store the TarInfo object for the asset file itself | |
if member.isfile(): # Ensure it's actually a file | |
asset_members[guid] = member | |
# else: print(f"Debug: Found directory named 'asset': {member.name}") # Optional debug | |
# else: print(f"Debug: Skipping member with unexpected structure: {member.name}") # Optional debug | |
# Second pass: Correlate and populate list | |
display_items = [] | |
for guid, asset_member_info in asset_members.items(): | |
if guid in pathnames: | |
display_path = pathnames[guid] | |
# Use normalized display path as key | |
self.file_map[display_path] = asset_member_info # Map display path -> asset TarInfo | |
display_items.append(display_path) | |
else: | |
# Asset file exists, but no corresponding pathname found? Log it. | |
print(f"Warning: Asset found for GUID {guid} ({asset_member_info.name}) but no corresponding pathname file.") | |
# Sort items alphabetically for display | |
display_items.sort(key=str.lower) # Case-insensitive sort | |
# Populate the listbox | |
for item in display_items: | |
self.listbox.insert(tk.END, item) | |
# Update button state after loading | |
self.on_listbox_select() | |
except tarfile.ReadError as e: | |
messagebox.showerror("Error Reading Archive", f"Failed to read archive structure:\n{e}") | |
self.destroy() # Close window on critical error | |
except Exception as e: | |
messagebox.showerror("Loading Error", f"An unexpected error occurred while loading package contents:\n{e}") | |
self.destroy() # Close window on critical error | |
def _prompt_and_extract_single(self, display_path): | |
"""Prompts user for save location and extracts a single file.""" | |
if display_path in self.file_map: | |
asset_member_info = self.file_map[display_path] | |
if not asset_member_info.isfile(): | |
messagebox.showwarning("Not a File", f"'{display_path}'\nappears to be a directory or other non-file entry within the package structure and cannot be extracted individually.") | |
return | |
# Propose a filename based on the pathname | |
default_filename = os.path.basename(display_path) | |
# Ask user where to save the file | |
save_path = filedialog.asksaveasfilename( | |
title=f"Extract '{default_filename}'", | |
initialfile=default_filename, | |
# Attempt to get a reasonable extension | |
defaultextension=os.path.splitext(default_filename)[1] or ".file", | |
filetypes=[("All Files", "*.*")] # Consider adding specific types if known | |
) | |
if save_path: # User didn't cancel | |
self._extract_file_core(asset_member_info, save_path, display_path_for_msg=display_path, show_success_msg=True) | |
else: | |
messagebox.showerror("Error", "Could not find internal mapping for the selected item.") | |
def _extract_file_core(self, member_info, output_path, display_path_for_msg=None, show_success_msg=False): | |
""" | |
Core logic to extract a single member to the specified output path. | |
Returns True on success, False on failure. | |
Optionally shows a success message box. | |
""" | |
if display_path_for_msg is None: | |
display_path_for_msg = os.path.basename(output_path) # Use filename if no display path given | |
try: | |
# Ensure output directory exists | |
os.makedirs(os.path.dirname(output_path), exist_ok=True) | |
# Re-open the tarfile for extraction (safer than keeping it open) | |
with tarfile.open(self.package_path, 'r') as tar: | |
extracted_file_obj = tar.extractfile(member_info) | |
if extracted_file_obj: | |
try: | |
# Open the destination file in binary write mode | |
with open(output_path, 'wb') as outfile: | |
# Copy content efficiently | |
shutil.copyfileobj(extracted_file_obj, outfile) | |
finally: | |
extracted_file_obj.close() # Ensure tar's file handle is closed | |
if show_success_msg: | |
messagebox.showinfo("Success", f"Successfully extracted:\n'{display_path_for_msg}'\n\nto:\n'{output_path}'") | |
return True # Indicate success | |
else: | |
# This case means tar.extractfile returned None, which is unusual for a file member | |
raise IOError(f"Could not get data stream for member '{member_info.name}' from tar archive.") | |
except PermissionError: | |
messagebox.showerror("Permission Error", f"Could not write to:\n'{output_path}'\n\nCheck folder permissions.") | |
return False | |
except tarfile.ReadError as e: | |
messagebox.showerror("Archive Read Error", f"Error reading archive data for '{member_info.name}':\n{e}") | |
return False | |
except OSError as e: | |
# Catch other OS errors like Disk Full, Invalid Pathname Chars (less likely with sanitization) | |
messagebox.showerror("File System Error", f"Could not write file '{os.path.basename(output_path)}':\n{e}") | |
return False | |
except Exception as e: | |
messagebox.showerror("Extraction Failed", f"Failed to extract '{member_info.name}' to '{output_path}':\n{type(e).__name__}: {e}") | |
return False | |
def _launch_file(self, file_path): | |
"""Launches a file using the default application or Notepad++ for .cs files.""" | |
try: | |
ext = os.path.splitext(file_path)[1].lower() | |
if ext == '.cs': | |
# Force launch .cs files with Notepad++ | |
try: | |
print(f"Attempting to launch '{file_path}' with {NOTEPAD_PLUS_PLUS_CMD}...") | |
# Use Popen for non-blocking launch | |
subprocess.Popen([NOTEPAD_PLUS_PLUS_CMD, file_path]) | |
# Give Notepad++ a moment to potentially load the file handle | |
# time.sleep(0.5) # Small delay - uncomment if needed, but test first | |
except FileNotFoundError: | |
messagebox.showerror("Notepad++ Not Found", | |
f"Could not find '{NOTEPAD_PLUS_PLUS_CMD}' in your system's PATH.\n" | |
f"Cannot open '{os.path.basename(file_path)}'.\n\n" | |
"Please ensure Notepad++ is installed and added to the PATH environment variable, " | |
"or modify the NOTEPAD_PLUS_PLUS_CMD variable in the script.") | |
except Exception as e_npp: | |
messagebox.showerror("Notepad++ Error", f"Failed to launch '{os.path.basename(file_path)}' with Notepad++:\n{e_npp}") | |
elif os.name == 'nt': | |
# Use os.startfile for other files on Windows | |
print(f"Attempting to launch '{file_path}' with default application...") | |
os.startfile(file_path) | |
# time.sleep(0.5) # Small delay - uncomment if needed | |
else: | |
messagebox.showinfo("Preview Not Supported", f"Automatic file preview is only supported on Windows (except for .cs with Notepad++).\nCannot launch: {os.path.basename(file_path)}") | |
except Exception as e: | |
messagebox.showerror("Launch Error", f"Could not launch file '{os.path.basename(file_path)}':\n{e}") | |
def _cleanup_preview_temp_dir(self): | |
"""Cleans up the temporary directory used for preview, if it exists.""" | |
if self._preview_temp_dir: | |
try: | |
print(f"Cleaning up preview temp directory: {self._preview_temp_dir.name}") | |
self._preview_temp_dir.cleanup() | |
except Exception as e: | |
# Log error, but don't bother the user too much about temp cleanup failure | |
print(f"Warning: Failed to clean up temporary preview directory '{self._preview_temp_dir.name}': {e}") | |
finally: | |
self._preview_temp_dir = None | |
# --- Main Execution --- | |
if __name__ == "__main__": | |
if len(sys.argv) < 2: | |
# Try to show a Tkinter message box even if the main window isn't up yet | |
root = tk.Tk() | |
root.withdraw() # Hide the empty root window | |
messagebox.showerror("Usage Error", "Please provide the path to a .unitypackage file.\n\nDrag-and-drop a .unitypackage onto the .exe or use 'Open With'.") | |
root.destroy() | |
sys.exit(1) | |
package_file_path = sys.argv[1] | |
# Basic check before initializing the full app | |
if not os.path.exists(package_file_path): | |
root = tk.Tk() | |
root.withdraw() # Hide the empty root window | |
messagebox.showerror("File Not Found", f"The specified unitypackage file does not exist:\n{package_file_path}") | |
root.destroy() | |
sys.exit(1) | |
app = UnityPackageViewer(package_file_path) | |
app.mainloop() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Change line 18 to your absolute path to Notepad++ for the .cs files preview. Build this as a Windows executable by installing pyinstaller with pip, then running "pyinstaller --windowed unitypackageutility.py" Once you have that executable, associate .unitypackage files with it in Windows. This is a way better workflow than importing these into a Unity project just to see what the package contains.