diff --git a/.github/workflows/python-build.yml b/.github/workflows/python-build.yml index d4ec562..ec192ed 100644 --- a/.github/workflows/python-build.yml +++ b/.github/workflows/python-build.yml @@ -22,10 +22,10 @@ jobs: run: | sudo apt update && \ sudo apt install -y ccache patchelf - - name: Set up Python 3.12 + - name: Set up Python 3.13 uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" - name: Install Dependencies run: | python -m pip install --upgrade pip @@ -49,10 +49,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Python 3.12 + - name: Set up Python 3.13 uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" - name: Install Dependencies run: | python -m pip install --upgrade pip @@ -79,7 +79,7 @@ jobs: - name: Set up Python 3.12 uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" - name: Install Dependencies run: | python -m pip install --upgrade pip @@ -105,10 +105,10 @@ jobs: - uses: actions/checkout@v4 - name: Enable Developer Command Prompt uses: ilammy/msvc-dev-cmd@v1.13.0 - - name: Set up Python 3.12 + - name: Set up Python 3.13 uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" - name: Install Dependencies run: | python -m pip install --upgrade pip @@ -129,10 +129,10 @@ jobs: - uses: actions/checkout@v4 - name: Enable Developer Command Prompt uses: ilammy/msvc-dev-cmd@v1.13.0 - - name: Set up Python 3.12 + - name: Set up Python 3.13 uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" - name: Install Dependencies run: | python -m pip install --upgrade pip diff --git a/NUSGet.py b/NUSGet.py index 643ced1..e978c0a 100644 --- a/NUSGet.py +++ b/NUSGet.py @@ -6,6 +6,7 @@ # nuitka-project: --standalone # nuitka-project: --macos-create-app-bundle # nuitka-project: --macos-app-icon={MAIN_DIRECTORY}/resources/icon.png +# nuitka-project: --macos-signed-app-name=dev.ninjacheetah.NUSGet # nuitka-project-if: {OS} == "Windows": # nuitka-project: --standalone # nuitka-project: --windows-icon-from-ico={MAIN_DIRECTORY}/resources/icon.png @@ -38,7 +39,7 @@ from modules.download_batch import run_nus_download_batch from modules.download_wii import run_nus_download_wii from modules.download_dsi import run_nus_download_dsi -nusget_version = "1.4.2" +nusget_version = "1.5.0" regions = {"World": ["41"], "USA/NTSC": ["45"], "Europe/PAL": ["50"], "Japan": ["4A"], "Korea": ["4B"], "China": ["43"], "Australia/NZ": ["55"]} @@ -81,6 +82,7 @@ class MainWindow(QMainWindow, Ui_MainWindow): self.ui.setupUi(self) self.threadpool = QThreadPool() self.ui.download_btn.clicked.connect(self.download_btn_pressed) + self.ui.open_output_btn.clicked.connect(self.open_output_dir) self.ui.script_btn.clicked.connect(self.script_btn_pressed) self.ui.custom_out_dir_btn.clicked.connect(self.choose_output_dir) self.ui.pack_archive_checkbox.toggled.connect( @@ -190,6 +192,16 @@ class MainWindow(QMainWindow, Ui_MainWindow): dsi_model = NUSGetTreeModel(dsi_database, root_name="DSi Titles") self.tree_models = [wii_model, vwii_model, dsi_model] self.trees = [self.ui.wii_title_tree, self.ui.vwii_title_tree, self.ui.dsi_title_tree] + # --------- + # UI Tweaks + # --------- + # Any misc UI tweaks that need to be done when the UI loads. + # Set the appropriate folder icon for the open output folder button depending on the theme. + if is_dark_theme(config_data): + icon = QIcon(os.path.join(os.path.dirname(__file__), "resources", "folder_white.svg")) + else: + icon = QIcon(os.path.join(os.path.dirname(__file__), "resources", "folder_black.svg")) + self.ui.open_output_btn.setIcon(icon) # Build proxy models required for searching self.proxy_models = [TIDFilterProxyModel(self.ui.wii_title_tree), TIDFilterProxyModel(self.ui.vwii_title_tree), TIDFilterProxyModel(self.ui.dsi_title_tree)] @@ -345,7 +357,7 @@ class MainWindow(QMainWindow, Ui_MainWindow): self.ui.archive_file_entry.setText(archive_name) danger_text = selected_title.danger # Add warning text to the log if the selected title has no ticket. - if selected_title.ticket is False: + if not selected_title.ticket: danger_text = danger_text + ("Note: This Title does not have a Ticket available, so it cannot be decrypted" " or packed into a WAD/TAD.") # Print log info about the selected title and version. @@ -410,24 +422,10 @@ class MainWindow(QMainWindow, Ui_MainWindow): "like the download to be saved.")) msg_box.exec() return + out_path = self.get_output_dir() + if out_path is None: + return self.lock_ui() - # Check for a custom output directory, and ensure that it's valid. If it is, then use that. - if self.ui.custom_out_dir_checkbox.isChecked() and self.ui.custom_out_dir_entry.text() != "": - out_path = pathlib.Path(self.ui.custom_out_dir_entry.text()) - if not out_path.exists() or not out_path.is_dir(): - msg_box = QMessageBox() - msg_box.setIcon(QMessageBox.Icon.Critical) - msg_box.setStandardButtons(QMessageBox.StandardButton.Ok) - msg_box.setDefaultButton(QMessageBox.StandardButton.Ok) - msg_box.setWindowTitle(app.translate("MainWindow", "Invalid Download Directory")) - msg_box.setText(app.translate("MainWindow", "The specified download directory does not exist!")) - msg_box.setInformativeText(app.translate("MainWindow", - "Please make sure the specified download directory exists," - " and that you have permission to access it.")) - msg_box.exec() - return - else: - out_path = out_folder # Create a new worker object to handle the download in a new thread. if self.ui.console_select_dropdown.currentText() == "DSi": worker = Worker(run_nus_download_dsi, out_path, self.ui.tid_entry.text(), @@ -603,8 +601,11 @@ class MainWindow(QMainWindow, Ui_MainWindow): archive_name = "" break titles.append(BatchTitleData(tid, title_version, console, archive_name)) + out_path = self.get_output_dir() + if out_path is None: + return self.lock_ui() - worker = Worker(run_nus_download_batch, out_folder, titles, self.ui.pack_archive_checkbox.isChecked(), + worker = Worker(run_nus_download_batch, out_path, titles, self.ui.pack_archive_checkbox.isChecked(), self.ui.keep_enc_checkbox.isChecked(), self.ui.create_dec_checkbox.isChecked(), self.ui.use_wiiu_nus_checkbox.isChecked(), self.ui.use_local_checkbox.isChecked(), self.ui.pack_vwii_mode_checkbox.isChecked(), self.ui.patch_ios_checkbox.isChecked()) @@ -612,6 +613,19 @@ class MainWindow(QMainWindow, Ui_MainWindow): worker.signals.progress.connect(self.download_progress_update) self.threadpool.start(worker) + def open_output_dir(self): + # Like all good things in life, this is a platform-dependent procedure. Did I say good? I meant annoying. + out_path = self.get_output_dir() + if out_path is None: + return + system = platform.system() + if system == "Windows": + subprocess.run(["explorer.exe", out_path]) + elif system == "Darwin": + subprocess.run(["open", out_path]) + else: + subprocess.run(["xdg-open", out_path]) + def choose_output_dir(self): # Use this handy convenience method to prompt the user to select a directory. Then we just need to validate # that the directory does indeed exist and is a directory, and we can save it as the output directory. @@ -619,20 +633,11 @@ class MainWindow(QMainWindow, Ui_MainWindow): "", QFileDialog.ShowDirsOnly | QFileDialog.DontResolveSymlinks) if selected_dir == "": return - out_path = pathlib.Path(selected_dir) - if not out_path.exists() or not out_path.is_dir(): - msg_box = QMessageBox() - msg_box.setIcon(QMessageBox.Icon.Critical) - msg_box.setStandardButtons(QMessageBox.StandardButton.Ok) - msg_box.setDefaultButton(QMessageBox.StandardButton.Ok) - msg_box.setWindowTitle(app.translate("MainWindow", "Invalid Download Directory")) - msg_box.setText(app.translate("MainWindow", "The specified download directory does not exist!")) - msg_box.setInformativeText(app.translate("MainWindow", - "Please make sure the download directory you want to use exists, and " - "that you have permission to access it.")) - msg_box.exec() + # Write it to the box and use existing validation code. Efficiency! + self.ui.custom_out_dir_entry.setText(selected_dir) + out_path = self.get_output_dir() + if out_path is None: return - self.ui.custom_out_dir_entry.setText(str(out_path)) config_data["out_path"] = str(out_path.absolute()) save_config(config_data) @@ -648,6 +653,44 @@ class MainWindow(QMainWindow, Ui_MainWindow): config_data["out_path"] = str(out_path.absolute()) save_config(config_data) + def get_output_dir(self) -> pathlib.Path | None: + # Whether a custom download directory is set. + if self.ui.custom_out_dir_checkbox.isChecked() and self.ui.custom_out_dir_entry.text() != "": + # Check for a custom output directory, and ensure that it's valid. If it is, then use that. Otherwise, + # return None and let the calling code determine how to continue. + out_path = pathlib.Path(self.ui.custom_out_dir_entry.text()) + if not out_path.exists() or not out_path.is_dir(): + msg_box = QMessageBox() + msg_box.setIcon(QMessageBox.Icon.Critical) + msg_box.setStandardButtons(QMessageBox.StandardButton.Ok) + msg_box.setDefaultButton(QMessageBox.StandardButton.Ok) + msg_box.setWindowTitle(app.translate("MainWindow", "Invalid Download Directory")) + msg_box.setText(app.translate("MainWindow", "The specified download directory does not exist!")) + msg_box.setInformativeText(app.translate("MainWindow", + "Please make sure the specified download directory exists," + " and that you have permission to access it.")) + msg_box.exec() + return None + return out_path + else: + # Default path if there's no custom download directory configured. Yay for platform differences! + if os.name == 'nt': + # This code is required because on Windows, the name of the downloads directory is localized based on your + # system's language. This means that literally "Downloads" isn't always going to exist, so we want to ask + # the registry what it's named on this particular machine. + import winreg + sub_key = r'SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders' + downloads_guid = '{374DE290-123F-4565-9164-39C4925E467B}' + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, sub_key) as key: + location = pathlib.Path(winreg.QueryValueEx(key, downloads_guid)[0]) + else: + location = pathlib.Path(os.path.expanduser('~')).joinpath('Downloads') + # Build the path by combining the path to the Downloads photo with "NUSGet". + out_folder = location.joinpath("NUSGet Downloads") + # Create the "NUSGet Downloads" directory if it doesn't exist. + out_folder.mkdir(exist_ok=True, parents=True) + return out_folder + def about_nusget(self): about_box = AboutNUSGet([nusget_version, version("libWiiPy"), version("libTWLPy")]) about_box.exec() @@ -676,29 +719,12 @@ class MainWindow(QMainWindow, Ui_MainWindow): if __name__ == "__main__": app = QApplication(sys.argv) # Load the database files, this will work for both the raw Python file and compiled standalone/onefile binaries. - database_file = open(os.path.join(os.path.dirname(__file__), "data", "wii-database.json")) + database_file = open(os.path.join(os.path.dirname(__file__), "data", "wii-database.json"), encoding="utf-8") wii_database = json.load(database_file) - database_file = open(os.path.join(os.path.dirname(__file__), "data", "vwii-database.json")) + database_file = open(os.path.join(os.path.dirname(__file__), "data", "vwii-database.json"), encoding="utf-8") vwii_database = json.load(database_file) - database_file = open(os.path.join(os.path.dirname(__file__), "data", "dsi-database.json")) + database_file = open(os.path.join(os.path.dirname(__file__), "data", "dsi-database.json"), encoding="utf-8") dsi_database = json.load(database_file) - # Load the user's Downloads directory, which of course requires different steps on Windows vs macOS/Linux. - if os.name == 'nt': - import winreg - sub_key = r'SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders' - downloads_guid = '{374DE290-123F-4565-9164-39C4925E467B}' - with winreg.OpenKey(winreg.HKEY_CURRENT_USER, sub_key) as key: - location = pathlib.Path(winreg.QueryValueEx(key, downloads_guid)[0]) - else: - # Silence a false linter warning about redeclaration, since this is actually only ever assigned once. - # noinspection PyRedeclaration - location = pathlib.Path(os.path.expanduser('~')).joinpath('Downloads') - # Build the path by combining the path to the Downloads photo with "NUSGet". - out_folder = location.joinpath("NUSGet Downloads") - # Create the "NUSGet Downloads" directory if it doesn't exist. In the future, this will be user-customizable, but - # this works for now, and avoids using a directory next to the binary (mostly an issue on macOS/Linux). - if not out_folder.is_dir(): - out_folder.mkdir() # Load the config path and then the configuration data, if it exists. If not, then we should initialize it and write # it out. diff --git a/README.md b/README.md index a29937d..6b930c2 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ A modern and supercharged NUS downloader built with Python and Qt6. Powered by l - - + + ## Features NUSGet allows you to download any content from the Nintendo Update Servers. Free content (content with a Ticket freely available on the servers) can be decrypted or packed directly into an installable archive (WAD/TAD). @@ -36,7 +36,7 @@ The following features are available for all supported consoles: For basic usage on all platforms, you can download the latest release for your operating system from [here](https://github.com/NinjaCheetah/NUSGet/releases/latest), and then run the executable. **Platform-Specific Notes:** -- **macOS:** To use NUSGet on macOS, you'll need to either open NUSGet.app using right-click -> Open, or by using the terminal command `xattr -d com.apple.quarantine NUSGet.app`. After doing either of those things once, you'll be able to double-click NUSGet to open it like you normally would for an app. Note that changes in macOS Sequoia require using the latter method. +- **macOS:** As of v1.4.3, NUSGet on macOS is signed with my Developer ID and can be run like any other Mac app. - **Windows:** On Windows, you'll likely need to allow NUSGet.exe in your antivirus program. This includes Windows Defender, which is almost guaranteed to prevent the app from being run. This is not because NUSGet is malicious in any way, it's just that NUSGet isn't popular enough to be "known" to Windows, and I don't have the expensive signing certificate necessary to work around this. If you're in doubt, you can look at all of NUSGet's code in this repository. - **Linux:** No special information applies on Linux, however you can build NUSGet yourself if you'd like to have it as an installed application with an icon that will appear in your favorite application launcher. See [here](https://github.com/NinjaCheetah/NUSGet?tab=readme-ov-file#for-linux-users) for more information. @@ -76,7 +76,7 @@ A huge thanks to all the wonderful translators who've helped make NUSGet availab - **German:** [@yeah-its-gloria](https://github.com/yeah-its-gloria) - **Italian:** [@LNLenost](https://github.com/LNLenost) - **Korean:** [@DDinghoya](https://github.com/DDinghoya) - - **Norwegian:** [@Rolfie](https://github.com/rolfiee) + - **Norwegian:** [@DandelionSprout](https://github.com/DandelionSprout), [@Rolfie](https://github.com/rolfiee) - **Romanian:** [@NotImplementedLife](https://github.com/NotImplementedLife) - **Spanish:** [@DarkMatterCore](https://github.com/DarkMatterCore) diff --git a/qt/py/ui_MainMenu.py b/qt/py/ui_MainMenu.py index 0e53182..3b97aa9 100644 --- a/qt/py/ui_MainMenu.py +++ b/qt/py/ui_MainMenu.py @@ -3,7 +3,7 @@ ################################################################################ ## Form generated from reading UI file 'MainMenu.ui' ## -## Created by: Qt User Interface Compiler version 6.9.0 +## Created by: Qt User Interface Compiler version 6.10.1 ## ## WARNING! All changes made in this file will be lost when recompiling UI file! ################################################################################ @@ -210,6 +210,13 @@ class Ui_MainWindow(object): self.horizontalLayout.addWidget(self.download_btn) + self.open_output_btn = QPushButton(self.centralwidget) + self.open_output_btn.setObjectName(u"open_output_btn") + icon1 = QIcon(QIcon.fromTheme(QIcon.ThemeIcon.FolderOpen)) + self.open_output_btn.setIcon(icon1) + + self.horizontalLayout.addWidget(self.open_output_btn) + self.script_btn = QPushButton(self.centralwidget) self.script_btn.setObjectName(u"script_btn") sizePolicy.setHeightForWidth(self.script_btn.sizePolicy().hasHeightForWidth()) @@ -371,7 +378,7 @@ class Ui_MainWindow(object): MainWindow.setCentralWidget(self.centralwidget) self.menubar = QMenuBar(MainWindow) self.menubar.setObjectName(u"menubar") - self.menubar.setGeometry(QRect(0, 0, 1010, 21)) + self.menubar.setGeometry(QRect(0, 0, 1010, 30)) self.menu_help = QMenu(self.menubar) self.menu_help.setObjectName(u"menu_help") self.menu_options = QMenu(self.menubar) @@ -431,6 +438,7 @@ class Ui_MainWindow(object): self.label_5.setText(QCoreApplication.translate("MainWindow", u"Console:", None)) self.console_select_dropdown.setCurrentText("") self.download_btn.setText(QCoreApplication.translate("MainWindow", u"Start Download", None)) + self.open_output_btn.setText("") self.script_btn.setText(QCoreApplication.translate("MainWindow", u"Run Script", None)) self.label_3.setText(QCoreApplication.translate("MainWindow", u"General Settings", None)) self.archive_file_entry.setPlaceholderText(QCoreApplication.translate("MainWindow", u"File Name", None)) @@ -445,7 +453,7 @@ class Ui_MainWindow(object): "hr { height: 1px; border-width: 0; }\n" "li.unchecked::marker { content: \"\\2610\"; }\n" "li.checked::marker { content: \"\\2612\"; }\n" -"
\n" +"\n" "