Merge major improvements from upstream

This commit is contained in:
2024-12-13 23:09:23 -05:00
16 changed files with 735 additions and 776 deletions

View File

@@ -6,8 +6,8 @@ from dataclasses import dataclass
@dataclass
class SelectedTitle:
# Class to store all components of a selected title to make tracking it easier.
class TitleData:
# Class to store all data for a Title.
tid: str
name: str
archive_name: str
@@ -15,7 +15,6 @@ class SelectedTitle:
ticket: bool
region: str
category: str
console: str
danger: str

View File

@@ -1,7 +1,6 @@
# "modules/download_dsi.py", licensed under the MIT license
# Copyright 2024 NinjaCheetah
import os
import pathlib
from typing import List, Tuple
@@ -27,14 +26,13 @@ def run_nus_download_dsi(out_folder: pathlib.Path, tid: str, version: str, pack_
# Create a new libTWLPy Title.
title = libTWLPy.Title()
# Make a directory for this title if it doesn't exist.
title_dir = pathlib.Path(os.path.join(out_folder, tid))
if not title_dir.is_dir():
title_dir.mkdir()
title_dir = out_folder.joinpath(tid)
title_dir.mkdir(exist_ok=True)
# Announce the title being downloaded, and the version if applicable.
if title_version is not None:
progress_callback.emit("Downloading title " + tid + " v" + str(title_version) + ", please wait...")
progress_callback.emit(f"Downloading title {tid} v{title_version}, please wait...")
else:
progress_callback.emit("Downloading title " + tid + " vLatest, please wait...")
progress_callback.emit(f"Downloading title {tid} vLatest, please wait...")
progress_callback.emit(" - Downloading and parsing TMD...")
# Download a specific TMD version if a version was specified, otherwise just download the latest TMD.
try:
@@ -47,25 +45,19 @@ def run_nus_download_dsi(out_folder: pathlib.Path, tid: str, version: str, pack_
except ValueError:
return -2
# Make a directory for this version if it doesn't exist.
version_dir = pathlib.Path(os.path.join(title_dir, str(title_version)))
if not version_dir.is_dir():
version_dir.mkdir()
version_dir = title_dir.joinpath(str(title_version))
version_dir.mkdir(exist_ok=True)
# Write out the TMD to a file.
tmd_out = open(os.path.join(version_dir, "tmd." + str(title_version)), "wb")
tmd_out.write(title.tmd.dump())
tmd_out.close()
version_dir.joinpath(f"tmd.{title_version}").write_bytes(title.tmd.dump())
# Use a local ticket, if one exists and "use local files" is enabled.
if use_local_chkbox is True and os.path.exists(os.path.join(version_dir, "tik")):
if use_local_chkbox and version_dir.joinpath("tik").exists():
progress_callback.emit(" - Parsing local copy of Ticket...")
local_ticket = open(os.path.join(version_dir, "tik"), "rb")
title.load_ticket(local_ticket.read())
title.load_ticket(version_dir.joinpath("tik").read_bytes())
else:
progress_callback.emit(" - Downloading and parsing Ticket...")
try:
title.load_ticket(libTWLPy.download_ticket(tid))
ticket_out = open(os.path.join(version_dir, "tik"), "wb")
ticket_out.write(title.ticket.dump())
ticket_out.close()
version_dir.joinpath("tik").write_bytes(title.ticket.dump())
except ValueError:
# If libTWLPy returns an error, then no ticket is available. Log this, and disable options requiring a
# ticket so that they aren't attempted later.
@@ -74,38 +66,27 @@ def run_nus_download_dsi(out_folder: pathlib.Path, tid: str, version: str, pack_
decrypt_contents_enabled = False
# Load the content record from the TMD, and download the content it lists. DSi titles only have one content.
title.load_content_records()
content_file_name = hex(title.tmd.content_record.content_id)[2:]
while len(content_file_name) < 8:
content_file_name = "0" + content_file_name
content_file_name = f"{title.tmd.content_record.content_id:08X}"
# Check for a local copy of the current content if "use local files" is enabled, and use it.
if use_local_chkbox is True and os.path.exists(os.path.join(version_dir, content_file_name)):
if use_local_chkbox and version_dir.joinpath(content_file_name).exists():
progress_callback.emit(" - Using local copy of content")
local_file = open(os.path.join(version_dir, content_file_name), "rb")
content = local_file.read()
content = version_dir.joinpath(content_file_name).read_bytes()
else:
progress_callback.emit(" - Downloading content (Content ID: " + str(title.tmd.content_record.content_id) +
", Size: " + str(title.tmd.content_record.content_size) + " bytes)...")
progress_callback.emit(f" - Downloading content (Content ID: {title.tmd.content_record.content_id}, Size: "
f"{title.tmd.content_record.content_size} bytes)...")
content = libTWLPy.download_content(tid, title.tmd.content_record.content_id)
progress_callback.emit(" - Done!")
# If keep encrypted contents is on, write out each content after its downloaded.
# If keep encrypted contents is on, write out the content after its downloaded.
if keep_enc_chkbox is True:
enc_content_out = open(os.path.join(version_dir, content_file_name), "wb")
enc_content_out.write(content)
enc_content_out.close()
version_dir.joinpath(content_file_name).write_bytes(content)
title.content.content = content
# If decrypt local contents is still true, decrypt each content and write out the decrypted file.
# If decrypt local contents is still true, decrypt the content and write out the decrypted file.
if decrypt_contents_enabled is True:
try:
progress_callback.emit(" - Decrypting content (Content ID: " + str(title.tmd.content_record.content_id)
+ ")...")
progress_callback.emit(f" - Decrypting content (Content ID: {title.tmd.content_record.content_id})...")
dec_content = title.get_content()
content_file_name = hex(title.tmd.content_record.content_id)[2:]
while len(content_file_name) < 8:
content_file_name = "0" + content_file_name
content_file_name = content_file_name + ".app"
dec_content_out = open(os.path.join(version_dir, content_file_name), "wb")
dec_content_out.write(dec_content)
dec_content_out.close()
content_file_name = f"{title.tmd.content_record.content_id:08X}.app"
version_dir.joinpath(content_file_name).write_bytes(dec_content)
except ValueError:
# If libWiiPy throws an error during decryption, return code -3. This should only be possible if using
# local encrypted contents that have been altered at present.
@@ -118,14 +99,12 @@ def run_nus_download_dsi(out_folder: pathlib.Path, tid: str, version: str, pack_
# Use a typed TAD name if there is one, and auto generate one based on the TID and version if there isn't.
progress_callback.emit("Packing TAD...")
if tad_file_name != "" and tad_file_name is not None:
if tad_file_name[-4:] != ".tad":
tad_file_name = tad_file_name + ".tad"
if tad_file_name[-4:].lower() != ".tad":
tad_file_name += ".tad"
else:
tad_file_name = tid + "-v" + str(title_version) + ".tad"
tad_file_name = f"{tid}-v{title_version}.tad"
# Have libTWLPy dump the TAD, and write that data out.
file = open(os.path.join(version_dir, tad_file_name), "wb")
file.write(title.dump_tad())
file.close()
version_dir.joinpath(tad_file_name).write_bytes(title.dump_tad())
progress_callback.emit("Download complete!")
# This is where the variables come in. If the state of these variables doesn't match the user's choice by this
# point, it means that they enabled decryption or TAD packing for a title that doesn't have a ticket. Return
@@ -134,12 +113,14 @@ def run_nus_download_dsi(out_folder: pathlib.Path, tid: str, version: str, pack_
return 1
return 0
def run_nus_download_dsi_batch(out_folder: pathlib.Path, titles: List[Tuple[str, str, str]], pack_tad_chkbox: bool, keep_enc_chkbox: bool,
decrypt_contents_chkbox: bool, use_local_chkbox: bool, progress_callback=None):
def run_nus_download_dsi_batch(out_folder: pathlib.Path, titles: List[Tuple[str, str, str]], pack_tad_chkbox: bool,
keep_enc_chkbox: bool, decrypt_contents_chkbox: bool, use_local_chkbox: bool,
progress_callback=None):
for title in titles:
result = run_nus_download_dsi(out_folder, title[0], title[1], pack_tad_chkbox, keep_enc_chkbox, decrypt_contents_chkbox, use_local_chkbox, f"{title[2]}-{title[1]}.tad", progress_callback)
result = run_nus_download_dsi(out_folder, title[0], title[1], pack_tad_chkbox, keep_enc_chkbox,
decrypt_contents_chkbox, use_local_chkbox, f"{title[2]}-{title[1]}.tad",
progress_callback)
if result != 0:
return result
progress_callback.emit(f"Batch download finished.")
return 0

View File

@@ -1,7 +1,6 @@
# "modules/download_wii.py", licensed under the MIT license
# Copyright 2024 NinjaCheetah
import os
import pathlib
from typing import List, Tuple
from .tkey import find_tkey
@@ -12,7 +11,6 @@ from libWiiPy.title.ticket import _TitleLimit
def run_nus_download_wii(out_folder: pathlib.Path, tid: str, version: str, pack_wad_chkbox: bool, keep_enc_chkbox: bool,
decrypt_contents_chkbox: bool, wiiu_nus_chkbox: bool, use_local_chkbox: bool,
repack_vwii_chkbox: bool, patch_ios: bool, wad_file_name: str, progress_callback=None):
#print(version)
# Actual NUS download function that runs in a separate thread.
# Immediately knock out any invalidly formatted Title IDs.
if len(tid) != 16:
@@ -31,14 +29,13 @@ def run_nus_download_wii(out_folder: pathlib.Path, tid: str, version: str, pack_
# Create a new libWiiPy Title.
title = libWiiPy.title.Title()
# Make a directory for this title if it doesn't exist.
title_dir = pathlib.Path(os.path.join(out_folder, tid))
if not title_dir.is_dir():
title_dir.mkdir()
title_dir = out_folder.joinpath(tid)
title_dir.mkdir(exist_ok=True)
# Announce the title being downloaded, and the version if applicable.
if title_version is not None:
progress_callback.emit("Downloading title " + tid + " v" + str(title_version) + ", please wait...")
progress_callback.emit(f"Downloading title {tid} v{title_version}, please wait...")
else:
progress_callback.emit("Downloading title " + tid + " vLatest, please wait...")
progress_callback.emit(f"Downloading title {tid} vLatest, please wait...")
progress_callback.emit(" - Downloading and parsing TMD...")
# Download a specific TMD version if a version was specified, otherwise just download the latest TMD.
try:
@@ -51,26 +48,20 @@ def run_nus_download_wii(out_folder: pathlib.Path, tid: str, version: str, pack_
except ValueError:
return -2
# Make a directory for this version if it doesn't exist.
version_dir = pathlib.Path(os.path.join(title_dir, str(title_version)))
if not version_dir.is_dir():
version_dir.mkdir()
version_dir = title_dir.joinpath(str(title_version))
version_dir.mkdir(exist_ok=True)
# Write out the TMD to a file.
tmd_out = open(os.path.join(version_dir, "tmd." + str(title_version)), "wb")
tmd_out.write(title.tmd.dump())
tmd_out.close()
version_dir.joinpath(f"tmd.{title_version}").write_bytes(title.tmd.dump())
# Use a local ticket, if one exists and "use local files" is enabled.
forge_ticket = False
if use_local_chkbox is True and os.path.exists(os.path.join(version_dir, "tik")):
if use_local_chkbox and version_dir.joinpath("tik").exists():
progress_callback.emit(" - Parsing local copy of Ticket...")
local_ticket = open(os.path.join(version_dir, "tik"), "rb")
title.load_ticket(local_ticket.read())
title.load_ticket(version_dir.joinpath("tik").read_bytes())
else:
progress_callback.emit(" - Downloading and parsing Ticket...")
try:
title.load_ticket(libWiiPy.title.download_ticket(tid, wiiu_endpoint=wiiu_nus_enabled))
ticket_out = open(os.path.join(version_dir, "tik"), "wb")
ticket_out.write(title.ticket.dump())
ticket_out.close()
version_dir.joinpath("tik").write_bytes(title.ticket.dump())
except ValueError:
# If libWiiPy returns an error, then no ticket is available. Try to forge a ticket after we download the
# content.
@@ -80,31 +71,22 @@ def run_nus_download_wii(out_folder: pathlib.Path, tid: str, version: str, pack_
title.load_content_records()
content_list = []
for content in range(len(title.tmd.content_records)):
# Generate the correct file name by converting the content ID into hex, minus the 0x, and then appending
# that to the end of 000000. I refuse to believe there isn't a better way to do this here and in libWiiPy.
content_file_name = hex(title.tmd.content_records[content].content_id)[2:]
while len(content_file_name) < 8:
content_file_name = "0" + content_file_name
# Generate the correct file name by converting the content ID into hex.
content_file_name = f"{title.tmd.content_records[content].content_id:08X}"
# Check for a local copy of the current content if "use local files" is enabled, and use it.
if use_local_chkbox is True and os.path.exists(os.path.join(version_dir,
content_file_name)):
progress_callback.emit(" - Using local copy of content " + str(content + 1) + " of " +
str(len(title.tmd.content_records)))
local_file = open(os.path.join(version_dir, content_file_name), "rb")
content_list.append(local_file.read())
if use_local_chkbox is True and version_dir.joinpath(content_file_name).exists():
progress_callback.emit(f" - Using local copy of content {content + 1} of {len(title.tmd.content_records)}")
content_list.append(version_dir.joinpath(content_file_name).read_bytes())
else:
progress_callback.emit(" - Downloading content " + str(content + 1) + " of " +
str(len(title.tmd.content_records)) + " (Content ID: " +
str(title.tmd.content_records[content].content_id) + ", Size: " +
str(title.tmd.content_records[content].content_size) + " bytes)...")
progress_callback.emit(f" - Downloading content {content + 1} of {len(title.tmd.content_records)} "
f"(Content ID: {title.tmd.content_records[content].content_id}, Size: "
f"{title.tmd.content_records[content].content_size} bytes)...")
content_list.append(libWiiPy.title.download_content(tid, title.tmd.content_records[content].content_id,
wiiu_endpoint=wiiu_nus_enabled))
progress_callback.emit(" - Done!")
# If keep encrypted contents is on, write out each content after its downloaded.
if keep_enc_chkbox is True:
enc_content_out = open(os.path.join(version_dir, content_file_name), "wb")
enc_content_out.write(content_list[content])
enc_content_out.close()
version_dir.joinpath(content_file_name).write_bytes(content_list[content])
title.content.content_list = content_list
# Try to forge a Ticket, if a common one wasn't available.
if forge_ticket is True:
@@ -143,17 +125,11 @@ def run_nus_download_wii(out_folder: pathlib.Path, tid: str, version: str, pack_
if decrypt_contents_enabled is True:
try:
for content in range(len(title.tmd.content_records)):
progress_callback.emit(" - Decrypting content " + str(content + 1) + " of " +
str(len(title.tmd.content_records)) + " (Content ID: " +
str(title.tmd.content_records[content].content_id) + ")...")
progress_callback.emit(f" - Decrypting content {content + 1} of {len(title.tmd.content_records)} "
f"(Content ID: {title.tmd.content_records[content].content_id})...")
dec_content = title.get_content_by_index(content)
content_file_name = hex(title.tmd.content_records[content].content_id)[2:]
while len(content_file_name) < 8:
content_file_name = "0" + content_file_name
content_file_name = content_file_name + ".app"
dec_content_out = open(os.path.join(version_dir, content_file_name), "wb")
dec_content_out.write(dec_content)
dec_content_out.close()
content_file_name = f"{title.tmd.content_records[content].content_id:08X}.app"
version_dir.joinpath(content_file_name).write_bytes(dec_content)
except ValueError:
# If libWiiPy throws an error during decryption, return code -3. This should only be possible if using
# local encrypted contents that have been altered at present.
@@ -165,8 +141,7 @@ def run_nus_download_wii(out_folder: pathlib.Path, tid: str, version: str, pack_
# vWii mode. (vWii mode does not have access to the vWii key, only Wii U mode has that.)
if repack_vwii_chkbox is True and (tid[3] == "7" or tid[7] == "7"):
progress_callback.emit(" - Re-encrypting Title Key with the common key...")
title_key_dec = title.ticket.get_title_key()
title_key_common = libWiiPy.title.encrypt_title_key(title_key_dec, 0, title.tmd.title_id)
title_key_common = libWiiPy.title.encrypt_title_key(title.ticket.get_title_key(), 0, title.tmd.title_id)
title.ticket.common_key_index = 0
title.ticket.title_key_enc = title_key_common
# Get the WAD certificate chain, courtesy of libWiiPy.
@@ -175,10 +150,10 @@ def run_nus_download_wii(out_folder: pathlib.Path, tid: str, version: str, pack_
# Use a typed WAD name if there is one, and auto generate one based on the TID and version if there isn't.
progress_callback.emit(" - Packing WAD...")
if wad_file_name != "" and wad_file_name is not None:
if wad_file_name[-4:] != ".wad":
wad_file_name = wad_file_name + ".wad"
if wad_file_name[-4:].lower() != ".wad":
wad_file_name += ".wad"
else:
wad_file_name = tid + "-v" + str(title_version) + ".wad"
wad_file_name = f"{tid}-v{title_version}.wad"
# If enabled (after we make sure it's an IOS), apply all main IOS patches.
if patch_ios and (tid[:8] == "00000001" and int(tid[-2:], 16) > 2):
progress_callback.emit(" - Patching IOS...")
@@ -191,9 +166,7 @@ def run_nus_download_wii(out_folder: pathlib.Path, tid: str, version: str, pack_
progress_callback.emit(" - No patches could be applied! Is this a stub IOS?")
title = ios_patcher.dump()
# Have libWiiPy dump the WAD, and write that data out.
file = open(os.path.join(version_dir, wad_file_name), "wb")
file.write(title.dump_wad())
file.close()
version_dir.joinpath(wad_file_name).write_bytes(title.dump_wad())
progress_callback.emit("Download complete!")
# This is where the variables come in. If the state of these variables doesn't match the user's choice by this
# point, it means that they enabled decryption or WAD packing for a title that doesn't have a ticket. Return
@@ -202,13 +175,15 @@ def run_nus_download_wii(out_folder: pathlib.Path, tid: str, version: str, pack_
return 1
return 0
def run_nus_download_wii_batch(out_folder: pathlib.Path, titles: List[Tuple[str, str, str]], pack_wad_chkbox: bool, keep_enc_chkbox: bool,
decrypt_contents_chkbox: bool, wiiu_nus_chkbox: bool, use_local_chkbox: bool,
repack_vwii_chkbox: bool, patch_ios: bool, progress_callback=None):
def run_nus_download_wii_batch(out_folder: pathlib.Path, titles: List[Tuple[str, str, str]], pack_wad_chkbox: bool,
keep_enc_chkbox: bool, decrypt_contents_chkbox: bool, wiiu_nus_chkbox: bool,
use_local_chkbox: bool, repack_vwii_chkbox: bool, patch_ios: bool,
progress_callback=None):
for title in titles:
result = run_nus_download_wii(out_folder, title[0], title[1], pack_wad_chkbox, keep_enc_chkbox, decrypt_contents_chkbox, wiiu_nus_chkbox, use_local_chkbox, repack_vwii_chkbox, patch_ios, f"{title[2]}-{title[1]}.wad", progress_callback)
result = run_nus_download_wii(out_folder, title[0], title[1], pack_wad_chkbox, keep_enc_chkbox,
decrypt_contents_chkbox, wiiu_nus_chkbox, use_local_chkbox, repack_vwii_chkbox,
patch_ios, f"{title[2]}-{title[1]}.wad", progress_callback)
if result != 0:
return result
progress_callback.emit(f"Batch download finished.")
return 0

136
modules/tree.py Normal file
View File

@@ -0,0 +1,136 @@
# "modules/tree.py", licensed under the MIT license
# Copyright 2024 NinjaCheetah
from modules.core import TitleData
from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt
from PySide6.QtGui import QIcon
class TreeItem:
def __init__(self, data, parent=None, metadata=None):
self.data = data
self.parent = parent
self.children = []
self.metadata = metadata # Store hidden metadata
def add_child(self, item):
self.children.append(item)
def child(self, row):
return self.children[row]
def child_count(self):
return len(self.children)
def column_count(self):
return len(self.data)
def data_at(self, column):
if 0 <= column < len(self.data):
return self.data[column]
return None
def row(self):
if self.parent:
return self.parent.children.index(self)
return 0
class NUSGetTreeModel(QAbstractItemModel):
def __init__(self, data, parent=None, root_name=""):
super().__init__(parent)
self.root_item = TreeItem([root_name])
self.setup_model_data(data, self.root_item)
def setup_model_data(self, title, parent):
if isinstance(title, dict):
for key, value in title.items():
if isinstance(value, list):
key_item = TreeItem([key, ""], parent)
parent.add_child(key_item)
for entry in value:
tid = entry.get("TID")
name = entry.get("Name")
versions = entry.get("Versions", {})
if tid:
tid_item = TreeItem([f"{tid} - {name}", ""], key_item)
key_item.add_child(tid_item)
for region, version_list in versions.items():
region_item = TreeItem([region, ""], tid_item)
tid_item.add_child(region_item)
for version in version_list:
danger = entry.get("Danger") if entry.get("Danger") is not None else ""
metadata = TitleData(entry.get("TID"), entry.get("Name"), entry.get("Archive Name"),
version, entry.get("Ticket"), region, key, danger)
public_versions = entry.get("Public Versions")
if public_versions is not None:
try:
public_ver = public_versions[str(version)]
version_str = f"v{version} ({public_ver})"
except KeyError:
version_str = f"v{version}"
else:
version_str = f"v{version}"
version_item = TreeItem([version_str, ""], region_item, metadata)
region_item.add_child(version_item)
def rowCount(self, parent=QModelIndex()):
if parent.isValid():
parent_item = parent.internalPointer()
else:
parent_item = self.root_item
return parent_item.child_count()
def columnCount(self, parent=QModelIndex()):
return self.root_item.column_count()
def data(self, index, role=Qt.DisplayRole):
if not index.isValid():
return None
item = index.internalPointer()
if role == Qt.DisplayRole:
item = index.internalPointer()
return item.data_at(index.column())
if role == Qt.DecorationRole and index.column() == 0:
# Check for icons based on the "Ticket" metadata only at the TID level
if item.parent and item.parent.data_at(0) == "System":
if item.metadata and "Ticket" in item.metadata:
if item.metadata["Ticket"]:
return QIcon.fromTheme("dialog-ok") # Checkmark icon
else:
return QIcon.fromTheme("dialog-cancel") # X icon
return None
def headerData(self, section, orientation, role=Qt.DisplayRole):
if orientation == Qt.Horizontal and role == Qt.DisplayRole:
return self.root_item.data_at(section)
return None
def index(self, row, column, parent=QModelIndex()):
if not self.hasIndex(row, column, parent):
return QModelIndex()
if not parent.isValid():
parent_item = self.root_item
else:
parent_item = parent.internalPointer()
child_item = parent_item.child(row)
if child_item:
return self.createIndex(row, column, child_item)
return QModelIndex()
def parent(self, index):
if not index.isValid():
return QModelIndex()
child_item = index.internalPointer()
parent_item = child_item.parent
if parent_item == self.root_item:
return QModelIndex()
return self.createIndex(parent_item.row(), 0, parent_item)