Skip to content

Instantly share code, notes, and snippets.

@beardordie
Created April 3, 2025 04:52
Show Gist options
  • Save beardordie/3cbf656b40442dea59dbf0528026ad79 to your computer and use it in GitHub Desktop.
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)
# 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()
@beardordie
Copy link
Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment