def save_plist(self): # Ensure the lists are the same try: with open(self.plist_path,"wb") as f: plist.dump(self.plist_data,f,sort_keys=False) return True except Exception as e: self.show_error("Error Saving","Could not save to {}! {}".format(os.path.basename(self.plist_path),e)) return False
def save_plist(self): try: with open(self.plist_path, "wb") as f: plist.dump(self.plist_data, f) except Exception as e: self.u.head("Error Saving Plist") print("\nCould not save {}:\n\n{}\n\n".format( self.plist_path, repr(e))) self.u.grab("Press [enter] to return...") return False return True
def make_plist(self, oc_acpi, cl_acpi, patches): # if not len(patches): return # No patches to add - bail repeat = False print("Building patches_OC and patches_Clover plists...") output = self.d.check_output(self.output) oc_plist = {} cl_plist = {} # Check for the plists if os.path.isfile(os.path.join(output,"patches_OC.plist")): e = os.path.join(output,"patches_OC.plist") with open(e, "rb") as f: oc_plist = plist.load(f) if os.path.isfile(os.path.join(output,"patches_Clover.plist")): e = os.path.join(output,"patches_Clover.plist") with open(e,"rb") as f: cl_plist = plist.load(f) # Ensure all the pathing is where it needs to be oc_plist = self.ensure_path(oc_plist,("ACPI","Add")) oc_plist = self.ensure_path(oc_plist,("ACPI","Patch")) cl_plist = self.ensure_path(cl_plist,("ACPI","SortedOrder")) cl_plist = self.ensure_path(cl_plist,("ACPI","DSDT","Patches")) # Add the .aml references if any(oc_acpi["Comment"] == x["Comment"] for x in oc_plist["ACPI"]["Add"]): print(" -> Add \"{}\" already in OC plist!".format(oc_acpi["Comment"])) else: oc_plist["ACPI"]["Add"].append(oc_acpi) if any(cl_acpi == x for x in cl_plist["ACPI"]["SortedOrder"]): print(" -> \"{}\" already in Clover plist!".format(cl_acpi)) else: cl_plist["ACPI"]["SortedOrder"].append(cl_acpi) # Iterate the patches for p in patches: if any(x["Comment"] == p["Comment"] for x in oc_plist["ACPI"]["Patch"]): print(" -> Patch \"{}\" already in OC plist!".format(p["Comment"])) else: print(" -> Adding Patch \"{}\" to OC plist!".format(p["Comment"])) oc_plist["ACPI"]["Patch"].append(self.get_oc_patch(p)) if any(x["Comment"] == p["Comment"] for x in cl_plist["ACPI"]["DSDT"]["Patches"]): print(" -> Patch \"{}\" already in Clover plist!".format(p["Comment"])) else: print(" -> Adding Patch \"{}\" to Clover plist!".format(p["Comment"])) cl_plist["ACPI"]["DSDT"]["Patches"].append(self.get_clover_patch(p)) # Write the plists with open(os.path.join(output,"patches_OC.plist"),"wb") as f: plist.dump(oc_plist,f) with open(os.path.join(output,"patches_Clover.plist"),"wb") as f: plist.dump(cl_plist,f)
def get_catalog_data(self, local=False): # Gets the data based on our current_catalog url = self.build_url(catalog=self.current_catalog, version=self.current_macos) self.u.head("Downloading Catalog") print("") if local: print("Checking locally for {}".format(self.plist)) cwd = os.getcwd() os.chdir(os.path.dirname(os.path.realpath(__file__))) if os.path.exists( os.path.join(os.path.dirname(os.path.realpath(__file__)), self.scripts, self.plist)): print(" - Found - loading...") try: with open( os.path.join(os.getcwd(), self.scripts, self.plist), "rb") as f: self.catalog_data = plist.load(f) os.chdir(cwd) return True except: print(" - Error loading - downloading instead...\n") os.chdir(cwd) else: print(" - Not found - downloading instead...\n") print("Currently downloading {} catalog from\n\n{}\n".format( self.current_catalog, url)) try: b = self.d.get_bytes(url) print("") self.catalog_data = plist.loads(b) except: print("Error downloading!") return False try: # Assume it's valid data - dump it to a local file if local or self.force_local: print(" - Saving to {}...".format(self.plist)) cwd = os.getcwd() os.chdir(os.path.dirname(os.path.realpath(__file__))) with open(os.path.join(os.getcwd(), self.scripts, self.plist), "wb") as f: plist.dump(self.catalog_data, f) os.chdir(cwd) except: print(" - Error saving!") return False return True
def parse_usb_txt(self,raw): model = self.choose_smbios(current=None,prompt="Please enter the target SMBIOS for this injector.") if not model: return self.u.head("Parsing USB Info") print("") print("Got SMBIOS: {}".format(model)) print("Walking UsbDumpEfi output...") try: output_plist = { "CFBundleDevelopmentRegion": "English", "CFBundleGetInfoString": "v1.0", "CFBundleIdentifier": "com.corpnewt.USBMap", "CFBundleInfoDictionaryVersion": "6.0", "CFBundleName": "USBMap", "CFBundlePackageType": "KEXT", "CFBundleShortVersionString": "1.0", "CFBundleSignature": "????", "CFBundleVersion": "1.0", "IOKitPersonalities": {}, "OSBundleRequired": "Root" } controllers = output_plist["IOKitPersonalities"] types = {"0":"OHCI","1":"OHCI","2":"EHCI","3":"XHCI"} # Use OHCI as a placeholder for 0, and 1 info = raw.split("UsbDumpEfi start")[1] last_name = None for line in info.split("\n"): line = line.strip() if not line: continue if line.startswith("Found"): # Got a controller addr = ":".join([str(int(x,16)) for x in line.split(" @ ")[-1].replace(".",":").split(":")]) t = types.get(line.split("speed ")[1].split(")")[0],"Unknown") last_name = t if last_name in controllers: n = 1 while True: temp = "{}-{}".format(last_name,n) if not temp in controllers: last_name = temp break n += 1 controllers[last_name] = { "CFBundleItentifier": "com.apple.driver.AppleUSBHostMergeProperties", "IOClass": "AppleUSBHostMergeProperties", "IOParentMatch": {"IOPropertyMatch":{"pcidebug":addr}}, "IOProviderClass":"AppleUSB{}PCI".format(t), "IOProviderMergeProperties": { "port-count": self.hex_data(self.hex_swap(hex(int(line.split("(")[1].split(" ports")[0]))[2:].upper().rjust(8,"0"))), "ports": {} }, "model": model } if t == "XHCI": controllers[last_name]["IOProviderMergeProperties"]["kUSBMuxEnabled"] = True elif line.startswith("Port") and last_name != None: usb_connector = 3 if "XHCI" in controllers[last_name]["IOProviderClass"] else 0 num = int(line.split("Port ")[1].split(" status")[0])+1 name = "UK{}".format(str(num).rjust(2,"0")) hex_num = self.hex_data(self.hex_swap(hex(num)[2:].upper().rjust(8,"0"))) controllers[last_name]["IOProviderMergeProperties"]["ports"][name] = {"UsbConnector":usb_connector,"port":hex_num} except Exception as e: return self.show_error("Error Parsing".format(os.path.basename(path)),e) print("Generating kexts...") if not os.path.exists(self.output): os.mkdir(self.output) for k,t in (("USBMap.kext","AppleUSBHostMergeProperties"),("USBMapLegacy.kext","AppleUSBMergeNub")): print(" - {}".format(k)) kp = os.path.join(self.output,k) if os.path.exists(kp): print(" --> Located existing {} - removing...".format(k)) shutil.rmtree(kp,ignore_errors=True) print(" --> Creating bundle structure...") os.makedirs(os.path.join(kp,"Contents")) print(" --> Setting IOClass types...") for c in controllers: controllers[c]["CFBundleItentifier"] = "com.apple.driver.{}".format(t) controllers[c]["IOClass"] = t print(" --> Writing Info.plist...") with open(os.path.join(kp,"Contents","Info.plist"),"wb") as f: plist.dump(output_plist,f) print(" - Saved to: {}".format(kp)) print("") print("Done.") print("") self.u.grab("Press [enter] to return...")
def snapshot(self, in_file=None, out_file=None, oc_folder=None, clean=False): oc_folder = self.u.check_path(oc_folder) if not oc_folder: print("OC folder passed does not exist!") exit(1) if not os.path.isdir(oc_folder): print("OC folder passed is not a directory!") exit(1) if in_file: in_file = self.u.check_path(in_file) if not in_file: print("Input plist passed does not exist!") exit(1) if os.path.isdir(in_file): print("Input plist passed is a directory!") exit(1) try: with open(in_file, "rb") as f: tree_dict = plist.load(f) except Exception as e: print("Error loading plist: {}".format(e)) exit(1) else: if not out_file: print("At least one input or output file must be provided.") exit(1) # We got an out file at least - create an empty dict for the in_file tree_dict = {} if not out_file: out_file = in_file # Verify folder structure - should be as follows: # OC # +- ACPI # | +- SSDT.aml # +- Drivers # | +- EfiDriver.efi # +- Kexts # | +- Something.kext # +- config.plist # +- Tools (Optional) # | +- SomeTool.efi # | +- SomeFolder # | | +- SomeOtherTool.efi oc_acpi = os.path.normpath(os.path.join(oc_folder, "ACPI")) oc_drivers = os.path.normpath(os.path.join(oc_folder, "Drivers")) oc_kexts = os.path.normpath(os.path.join(oc_folder, "Kexts")) oc_tools = os.path.normpath(os.path.join(oc_folder, "Tools")) oc_efi = os.path.normpath(os.path.join(oc_folder, "OpenCore.efi")) for x in (oc_acpi, oc_drivers, oc_kexts): if not os.path.exists(x): print("Incorrect OC Folder Struction - {} does not exist.". format(x)) exit(1) if x != oc_efi and not os.path.isdir(x): print( "Incorrect OC Folder Struction - {} exists, but is not a directory." .format(x)) exit(1) # Folders are valid - lets work through each section # Let's get the hash of OpenCore.efi, compare to a known list, and then compare that version to our snapshot_version if found hasher = hashlib.md5() try: with open(oc_efi, "rb") as f: hasher.update(f.read()) oc_hash = hasher.hexdigest() except: oc_hash = "" # Couldn't determine hash :( # Let's get the version of the snapshot that matches our target, and that matches our hash if any latest_snap = {} # Highest min_version target_snap = {} # Matches our hash for snap in self.snapshot_data: hashes = snap.get("release_hashes", []) hashes.extend(snap.get("debug_hashes", [])) # Retain the highest version we see if snap.get("min_version", "0.0.0") > latest_snap.get( "min_version", "0.0.0"): latest_snap = snap # Also retain the last snap that matches our hash if len(oc_hash) and (oc_hash in snap.get("release_hashes", []) or oc_hash in snap.get("debug_hashes", [])): target_snap = snap if not target_snap: target_snap = latest_snap # Apply our snapshot values acpi_add = target_snap.get("acpi_add", {}) kext_add = target_snap.get("kext_add", {}) tool_add = target_snap.get("tool_add", {}) driver_add = target_snap.get("driver_add", {}) long_paths = [ ] # We'll add any paths that exceed the OC_STORAGE_SAFE_PATH_MAX of 128 chars # ACPI is first, we'll iterate the .aml files we have and add what is missing # while also removing what exists in the plist and not in the folder. # If something exists in the table already, we won't touch it. This leaves the # enabled and comment properties untouched. # # Let's make sure we have the ACPI -> Add sections in our config # We're going to replace the whole list if not "ACPI" in tree_dict or not isinstance(tree_dict["ACPI"], dict): tree_dict["ACPI"] = {"Add": []} if not "Add" in tree_dict["ACPI"] or not isinstance( tree_dict["ACPI"]["Add"], list): tree_dict["ACPI"]["Add"] = [] # Now we walk the existing add values new_acpi = [] for path, subdirs, files in os.walk(oc_acpi): for name in files: if not name.startswith(".") and name.lower().endswith(".aml"): new_acpi.append( os.path.join(path, name)[len(oc_acpi):].replace( "\\", "/").lstrip("/")) add = [] if clean else tree_dict["ACPI"]["Add"] for aml in sorted(new_acpi, key=lambda x: x.lower()): if aml.lower() in [ x.get("Path", "").lower() for x in add if isinstance(x, dict) ]: # Found it - skip continue # Doesn't exist, add it new_aml_entry = { "Comment": os.path.basename(aml), "Enabled": True, "Path": aml } # Add our snapshot custom entries, if any for x in acpi_add: new_aml_entry[x] = acpi_add[x] add.append(new_aml_entry) new_add = [] for aml in add: if not isinstance(aml, dict): # Not the right type - skip it continue if not aml.get("Path", "").lower() in [x.lower() for x in new_acpi]: # Not there, skip continue new_add.append(aml) # Check path length long_paths.extend(self.check_path_length(aml)) tree_dict["ACPI"]["Add"] = new_add # Now we need to walk the kexts if not "Kernel" in tree_dict or not isinstance(tree_dict["Kernel"], dict): tree_dict["Kernel"] = {"Add": []} if not "Add" in tree_dict["Kernel"] or not isinstance( tree_dict["Kernel"]["Add"], list): tree_dict["Kernel"]["Add"] = [] kext_list = [] # We need to gather a list of all the files inside that and with .efi for path, subdirs, files in os.walk(oc_kexts): for name in sorted(subdirs, key=lambda x: x.lower()): if name.startswith(".") or not name.lower().endswith(".kext"): continue kdict = { # "Arch":"Any", "BundlePath": os.path.join(path, name)[len(oc_kexts):].replace( "\\", "/").lstrip("/"), "Comment": name, "Enabled": True, # "MaxKernel":"", # "MinKernel":"", "ExecutablePath": "" } # Add our entries from kext_add as needed for y in kext_add: kdict[y] = kext_add[y] # Get the Info.plist plist_full_path = plist_rel_path = None for kpath, ksubdirs, kfiles in os.walk(os.path.join( path, name)): for kname in kfiles: if kname.lower() == "info.plist": plist_full_path = os.path.join(kpath, kname) plist_rel_path = plist_full_path[ len(os.path.join(path, name)):].replace( "\\", "/").lstrip("/") break if plist_full_path: break # Found it - break else: # Didn't find it - skip continue kdict["PlistPath"] = plist_rel_path # Let's load the plist and check for other info try: with open(plist_full_path, "rb") as f: info_plist = plist.load(f) kinfo = { "CFBundleIdentifier": info_plist.get("CFBundleIdentifier", None), "OSBundleLibraries": info_plist.get("OSBundleLibraries", []) } if info_plist.get("CFBundleExecutable", None): if not os.path.exists( os.path.join( path, name, "Contents", "MacOS", info_plist["CFBundleExecutable"])): continue # Requires an executable that doesn't exist - bail kdict[ "ExecutablePath"] = "Contents/MacOS/" + info_plist[ "CFBundleExecutable"] except Exception as e: continue # Something else broke here - bail # Should have something valid here kext_list.append((kdict, kinfo)) bundle_list = [x[0].get("BundlePath", "") for x in kext_list] kexts = [] if clean else tree_dict["Kernel"]["Add"] original_kexts = [ x for x in kexts if x.get("BundlePath", "") in bundle_list ] # get the original load order for comparison purposes - but omit any that no longer exist for kext, info in kext_list: if kext["BundlePath"].lower() in [ x.get("BundlePath", "").lower() for x in kexts if isinstance(x, dict) ]: # Already have it, skip continue # We need it, it seems kexts.append(kext) new_kexts = [] for kext in kexts: if not isinstance(kext, dict): # Not a dict - skip it continue if not kext.get("BundlePath", "").lower() in [ x[0]["BundlePath"].lower() for x in kext_list ]: # Not there, skip it continue new_kexts.append(kext) # Let's check inheritance via the info # We need to ensure that no 2 kexts consider each other as parents unordered_kexts = [] for x in new_kexts: x = next( (y for y in kext_list if y[0].get("BundlePath", "") == x.get("BundlePath", "")), None) if not x: continue parents = [ next( (z for z in new_kexts if z.get("BundlePath", "") == y[0].get("BundlePath", "")), []) for y in kext_list if y[1].get("CFBundleIdentifier", None) in x[1].get( "OSBundleLibraries", []) ] children = [ next( (z for z in new_kexts if z.get("BundlePath", "") == y[0].get("BundlePath", "")), []) for y in kext_list if x[1].get("CFBundleIdentifier", None) in y[1].get( "OSBundleLibraries", []) ] parents = [ y for y in parents if not y in children and not y.get("BundlePath", "") == x[0].get("BundlePath", "") ] unordered_kexts.append({"kext": x[0], "parents": parents}) ordered_kexts = [] disabled_parents = [] while len( unordered_kexts ): # This could be dangerous if things aren't properly prepared above kext = unordered_kexts.pop(0) if len(kext["parents"]): disabled_parents.extend([ x.get("BundlePath", "") for x in kext["parents"] if x.get("Enabled", True) == False and not x.get("BundlePath", "") in disabled_parents ]) if not all(x in ordered_kexts for x in kext["parents"]): unordered_kexts.append(kext) continue ordered_kexts.append( next(x for x in new_kexts if x.get("BundlePath", "") == kext["kext"].get( "BundlePath", ""))) # Let's compare against the original load order - to prevent mis-prompting missing_kexts = [x for x in ordered_kexts if not x in original_kexts] original_kexts.extend(missing_kexts) # Let's walk both lists and gather all kexts that are in different spots rearranged = [] while True: check1 = [ x.get("BundlePath", "") for x in ordered_kexts if not x.get("BundlePath", "") in rearranged ] check2 = [ x.get("BundlePath", "") for x in original_kexts if not x.get("BundlePath", "") in rearranged ] out_of_place = next( (x for x in range(len(check1)) if check1[x] != check2[x]), None) if out_of_place == None: break rearranged.append(check2[out_of_place]) # Verify if the load order changed - and prompt the user if need be if len(rearranged): print( "\nIncorrect kext load order has been corrected:\n\n{}".format( "\n".join(rearranged))) ordered_kexts = original_kexts # We didn't want to update it if len(disabled_parents): print("\nDisabled parent kexts have been enabled:\n\n{}".format( "\n".join(disabled_parents))) for x in ordered_kexts: # Walk our kexts and enable the parents if x.get("BundlePath", "") in disabled_parents: x["Enabled"] = True # Finally - we walk the kexts and ensure that we're not loading the same CFBundleIdentifier more than once enabled_kexts = [] duplicate_bundles = [] duplicates_disabled = [] for kext in ordered_kexts: # Check path length long_paths.extend(self.check_path_length(kext)) temp_kext = {} # Shallow copy the kext entry to avoid changing it in ordered_kexts for x in kext: temp_kext[x] = kext[x] duplicates_disabled.append(temp_kext) # Ignore if alreday disabled if not temp_kext.get("Enabled", False): continue # Get the original info info = next((x for x in kext_list if x[0].get("BundlePath", "") == temp_kext.get("BundlePath", "")), None) if not info or not info[1].get("CFBundleIdentifier", None): continue # Broken info # Let's see if it's already in enabled_kexts - and compare the Min/Max/Match Kernel options temp_min, temp_max = self.get_min_max_from_kext( temp_kext, "MatchKernel" in kext_add) # Gather a list of like IDs comp_kexts = [ x for x in enabled_kexts if x[1]["CFBundleIdentifier"] == info[1]["CFBundleIdentifier"] ] # Walk the comp_kexts, and disable if we find an overlap for comp_info in comp_kexts: comp_kext = comp_info[0] # Gather our min/max comp_min, comp_max = self.get_min_max_from_kext( comp_kext, "MatchKernel" in kext_add) # Let's see if we don't overlap if temp_min > comp_max or temp_max < comp_min: # We're good, continue continue # We overlapped - let's disable it temp_kext["Enabled"] = False # Add it to the list - then break out of this loop duplicate_bundles.append(temp_kext.get("BundlePath", "")) break # Check if we ended up disabling temp_kext, and if not - add it to the enabled_kexts list if temp_kext.get("Enabled", False): enabled_kexts.append((temp_kext, info[1])) # Check if we have duplicates - and offer to disable them if len(duplicate_bundles): print("\nDuplicate CFBundleIdentifiers have been disabled:\n\n{}". format("\n".join(duplicate_bundles))) ordered_kexts = duplicates_disabled tree_dict["Kernel"]["Add"] = ordered_kexts # Let's walk the Tools folder if it exists if not "Misc" in tree_dict or not isinstance(tree_dict["Misc"], dict): tree_dict["Misc"] = {"Tools": []} if not "Tools" in tree_dict["Misc"] or not isinstance( tree_dict["Misc"]["Tools"], list): tree_dict["Misc"]["Tools"] = [] if os.path.exists(oc_tools) and os.path.isdir(oc_tools): tools_list = [] # We need to gather a list of all the files inside that and with .efi for path, subdirs, files in os.walk(oc_tools): for name in files: if not name.startswith(".") and name.lower().endswith( ".efi"): # Save it new_tool_entry = { # "Arguments":"", # "Auxiliary":True, "Name": name, "Comment": name, "Enabled": True, "Path": os.path.join(path, name)[len(oc_tools):].replace( "\\", "/").lstrip("/") # Strip the /Volumes/EFI/ } # Add our snapshot custom entries, if any for x in tool_add: new_tool_entry[x] = tool_add[x] tools_list.append(new_tool_entry) tools = [] if clean else tree_dict["Misc"]["Tools"] for tool in sorted(tools_list, key=lambda x: x.get("Path", "").lower()): if tool["Path"].lower() in [ x.get("Path", "").lower() for x in tools if isinstance(x, dict) ]: # Already have it, skip continue # We need it, it seems tools.append(tool) new_tools = [] for tool in tools: if not isinstance(tool, dict): # Not a dict - skip it continue if not tool.get("Path", "").lower() in [ x["Path"].lower() for x in tools_list ]: # Not there, skip it continue new_tools.append(tool) # Check path length long_paths.extend(self.check_path_length(tool)) tree_dict["Misc"]["Tools"] = new_tools else: # Make sure our Tools list is empty tree_dict["Misc"]["Tools"] = [] # Last we need to walk the .efi drivers if not "UEFI" in tree_dict or not isinstance(tree_dict["UEFI"], dict): tree_dict["UEFI"] = {"Drivers": []} if not "Drivers" in tree_dict["UEFI"] or not isinstance( tree_dict["UEFI"]["Drivers"], list): tree_dict["UEFI"]["Drivers"] = [] if os.path.exists(oc_drivers) and os.path.isdir(oc_drivers): drivers_list = [] # We need to gather a list of all the files inside that and with .efi for path, subdirs, files in os.walk(oc_drivers): for name in files: if not name.startswith(".") and name.lower().endswith( ".efi"): # Check if we're using the new approach - or just listing the paths if not driver_add: drivers_list.append( os.path.join( path, name)[len(oc_drivers):].replace( "\\", "/").lstrip( "/")) # Strip the /Volumes/EFI/ else: new_driver_entry = { # "Arguments": "", "Enabled": True, "Path": os.path.join( path, name)[len(oc_drivers):].replace( "\\", "/").lstrip( "/") # Strip the /Volumes/EFI/ } # Add our snapshot custom entries, if any for x in driver_add: new_driver_entry[x] = name if x.lower( ) == "comment" else driver_add[x] drivers_list.append(new_driver_entry) drivers = [] if clean else tree_dict["UEFI"]["Drivers"] for driver in sorted(drivers_list, key=lambda x: x.get("Path", "").lower() if driver_add else x): if not driver_add: # Old way if not isinstance(driver, (str, unicode)) or driver.lower() in [ x.lower() for x in drivers if isinstance(x, (str, unicode)) ]: continue else: if driver["Path"].lower() in [ x.get("Path", "").lower() for x in drivers if isinstance(x, dict) ]: # Already have it, skip continue # We need it, it seems drivers.append(driver) new_drivers = [] for driver in drivers: if not driver_add: # Old way if not isinstance( driver, (str, unicode)) or not driver.lower() in [ x.lower() for x in drivers_list if isinstance(x, (str, unicode)) ]: continue else: if not isinstance(driver, dict): # Not a dict - skip it continue if not driver.get("Path", "").lower() in [ x["Path"].lower() for x in drivers_list ]: # Not there, skip it continue new_drivers.append(driver) # Check path length long_paths.extend(self.check_path_length(driver)) tree_dict["UEFI"]["Drivers"] = new_drivers else: # Make sure our Drivers list is empty tree_dict["UEFI"]["Drivers"] = [] # Check if we have any paths that are too long if long_paths: formatted = [] for entry in long_paths: item, name, keys = entry if isinstance(item, str): # It's an older string path formatted.append(name) elif isinstance(item, dict): formatted.append("{} -> {}".format(name, ", ".join(keys))) # Show the warning of lengthy paths print( "\nThe following exceed the {:,} character safe path max declared by OpenCore\nand may not work as intended:\n\n{}" .format(self.safe_path_length, "\n".join(formatted))) try: with open(out_file, "wb") as f: plist.dump(tree_dict, f) print("\nOutput saved to: {}".format(out_file)) except Exception as e: print("Failed to write output plist: {}".format(e)) exit(1)