diff --git a/README.md b/README.md index 60cb9c3..01dbe36 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # RetroUFO -[![Platform](https://img.shields.io/badge/platform-linux%20%7C%20windows-yellow.svg)](https://www.youtube.com/watch?v=NLGoKxh8Aq4) +[![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-yellow.svg)](https://www.youtube.com/watch?v=NLGoKxh8Aq4) [![Python Version](https://img.shields.io/pypi/pyversions/Django.svg)](https://www.python.org/downloads/) [![License.](https://img.shields.io/github/license/mashape/apistatus.svg)](https://opensource.org/licenses/MIT) A ~~messy~~ Python script that grabs the latest version of every libretro core from the [build bot](https://buildbot.libretro.com/). *** -### Usage +### Usage-CLI Just run the script with _Python 3_: @@ -21,6 +21,28 @@ If you are more of a advance user, and want to do things a bit more manually, yo python3 ./RetroUFO.py --help ``` +### Usage-GUI + +The GUI script uses [Qt for Python](https://wiki.qt.io/Qt_for_Python) ([PySide2](https://pypi.org/project/PySide2/)). So you can make sure you have that package installed by running: +```bash +pip3 install --user PySide2 +``` +After that you can just run the script like so: +```bash +python3 ./RetroUFO_GUI.py +``` + +You can then just click the `Grab Cores` button at the bottom and then you should be all set. + +![](screenshots/grab_cores.gif) + +If you would like to grab cores for a different platform or architecture you can override which supported cores it grabs. + +![](screenshots/custom_platform.gif) + +If you have your core directory set somewhere special you can override where the cores extract to. + +![](screenshots/custom_location.gif) *** ### TO-DO @@ -32,5 +54,6 @@ python3 ./RetroUFO.py --help - ~~Auto detect platform & architecture~~ - Download progress bar - ~~Keep downloaded archives~~ -- Give better console output +- ~~Make GUI~~ - Real error handling +- Support for ARM detection diff --git a/RetroUFO.py b/RetroUFO.py index d2226c9..849bf43 100755 --- a/RetroUFO.py +++ b/RetroUFO.py @@ -4,7 +4,7 @@ Grabs the latest version of every libretro core from the build bot. """ __author__ = "Melon Bread" -__version__ = "0.8.0" +__version__ = "0.9.0" __license__ = "MIT" import argparse @@ -12,7 +12,6 @@ import os import platform import sys import zipfile -from pathlib import Path from shutil import rmtree from urllib.request import urlretrieve @@ -20,8 +19,9 @@ URL = 'https://buildbot.libretro.com/nightly' # These are the default core locations with normal RetroArch installs based off of 'retroarch.default.cfg` CORE_LOCATION = { - 'linux': '{}/.config/retroarch/cores'.format(Path.home()), - 'windows': '{}/AppData/Roaming/RetroArch/cores'.format(Path.home()) + 'linux': '{}/.config/retroarch/cores'.format(os.path.expanduser('~')), + 'apple/osx': '/Applications/RetroArch.app/Contents/Resources/cores', # macOS + 'windows': '{}/AppData/Roaming/RetroArch/cores'.format(os.path.expanduser('~')) } @@ -29,7 +29,7 @@ def main(_args): """ Where the magic happens """ # If a platform and/or architecture is not supplied it is grabbed automatically - platform = _args.platform if _args.platform else get_platform() # TODO: rename this var to prevent conflict + target_platform = _args.platform if _args.platform else get_platform() architecture = _args.architecture if _args.architecture else get_architecture() location = _args.location if _args.location else CORE_LOCATION[platform] @@ -45,7 +45,8 @@ def get_platform(): if platform.system() == 'Linux': return 'linux' - + elif platform.system() == 'Darwin': # macOS + return 'apple/osx' elif platform.system() == 'Windows' or 'MSYS_NT' in platform.system(): # Checks for MSYS environment as well return 'windows' else: diff --git a/RetroUFO_GUI.py b/RetroUFO_GUI.py new file mode 100755 index 0000000..829eddc --- /dev/null +++ b/RetroUFO_GUI.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +""" +Grabs the latest version of every libretro core from the build bot. +""" + +__author__ = "Melon Bread" +__version__ = "0.9.0" +__license__ = "MIT" + +import os +import platform +import sys +import zipfile +from shutil import rmtree +from urllib.request import urlretrieve + +from PySide2.QtCore import QThread, Signal +from PySide2.QtGui import QTextCursor +from PySide2.QtWidgets import (QApplication, QCheckBox, QComboBox, QDialog, + QFileDialog, QLineEdit, QPushButton, QTextEdit, + QVBoxLayout, QMessageBox) + +URL = 'https://buildbot.libretro.com/nightly' + +# These are the default core locations with normal RetroArch installs based off of 'retroarch.default.cfg` +CORE_LOCATION = { + 'linux': '{}/.config/retroarch/cores'.format(os.path.expanduser('~')), + 'apple/osx': '/Applications/RetroArch.app/Contents/Resources/cores', # macOS + 'windows': '{}/AppData/Roaming/RetroArch/cores'.format(os.path.expanduser('~')) +} + + +class GrabThread(QThread): + add_to_log = Signal(str) + lock = Signal(bool) + + def __init__(self, _platform, _architecture, _location): + QThread.__init__(self) + self.platform = _platform + self.architecture = _architecture + self.location = _location + + def __del__(self): + self.wait() + + def run(self): + self.lock.emit(True) + self.add_to_log.emit('~Starting UFO Grabber~\n') + self.download_cores(self.platform, self.architecture) + self.extract_cores(self.location) + self.lock.emit(False) + + def create_dir(self, _name): + if not os.path.isdir(_name): + os.makedirs(_name) + + def obtain_core_list(self, _platform, _architecture): + urlretrieve( + '{}/{}/{}/latest/.index-extended'.format( + URL, _platform, _architecture), 'cores/index') + self.add_to_log.emit('Obtained core index!\n') + + def download_cores(self, _platform, _architecture): + """ Downloads every core to the working directory """ + + cores = [] + + # Makes core directory to store archives if needed + self.create_dir("cores") + + # Downloads a list of all the cores available + self.obtain_core_list(_platform, _architecture) + + # Adds all the core's file names to a list + core_index = open('cores/index') + + for line in core_index: + file_name = line.split(' ', 2)[2:] + cores.append(file_name[0].rstrip()) + core_index.close() + cores.sort() + + # Downloads each core from the list + self.add_to_log.emit('Downloading Cores\n') + for core in cores: + urlretrieve( + '{}/{}/{}/latest/{}'.format(URL, _platform, _architecture, + core), 'cores/{}'.format(core)) + self.add_to_log.emit('Downloaded {} ...'.format(core)) + + # Removes index file for easier extraction + os.remove('cores/index') + + def extract_cores(self, _location): + """ Extracts each downloaded core to the RA core directory """ + self.add_to_log.emit('\nExtracting all cores to: {}\n'.format(_location)) + + for file in os.listdir('cores'): + archive = zipfile.ZipFile('cores/{}'.format(file)) + archive.extractall(_location) + self.add_to_log.emit('Extracted {} ...'.format(file)) + + +class Form(QDialog): + def __init__(self, parent=None): + super(Form, self).__init__(parent) + self.setWindowTitle('RetroUFO') + + # Create widgets + self.chkboxPlatformDetect = QCheckBox('Platform Auto-Detect') + self.chkboxPlatformDetect.setChecked(True) + self.chkboxPlatformDetect.stateChanged.connect(self.auto_detect) + + self.cmbboxPlatform = QComboBox() + self.cmbboxPlatform.setEnabled(False) + self.cmbboxPlatform.setEditable(False) + self.cmbboxPlatform.addItem('Linux') + self.cmbboxPlatform.addItem('macOS') + self.cmbboxPlatform.addItem('Windows') + + self.cmbboxArchitecture = QComboBox() + self.cmbboxArchitecture.setEnabled(False) + self.cmbboxArchitecture.setEditable(False) + self.cmbboxArchitecture.addItem('x86') + self.cmbboxArchitecture.addItem('x86_64') + + self.chkboxLocationDetect = QCheckBox('Core Location Auto-Detect') + self.chkboxLocationDetect.setChecked(True) + self.chkboxLocationDetect.stateChanged.connect(self.auto_location) + + self.leditCoreLocation = QLineEdit('') + self.leditCoreLocation.setEnabled(False) + + self.btnCoreLocation = QPushButton('...') + self.btnCoreLocation.setEnabled(False) + self.btnCoreLocation.clicked.connect(self.choose_location) + + self.teditLog = QTextEdit() + self.teditLog.setReadOnly(True) + + self.tcsrLog = QTextCursor(self.teditLog.document()) + + self.chkboxKeepDownload = QCheckBox('Keep Downloaded Cores') + self.chkboxKeepDownload.setChecked(False) + + self.btnGrabCores = QPushButton('Grab Cores') + self.btnGrabCores.clicked.connect(self.grab_cores) + + # Create layout and add widgets + self.formLayout = QVBoxLayout() + self.formLayout.addWidget(self.chkboxPlatformDetect) + self.formLayout.addWidget(self.cmbboxPlatform) + self.formLayout.addWidget(self.cmbboxArchitecture) + self.formLayout.addWidget(self.chkboxLocationDetect) + self.formLayout.addWidget(self.leditCoreLocation) + self.formLayout.addWidget(self.btnCoreLocation) + self.formLayout.addWidget(self.teditLog) + self.formLayout.addWidget(self.chkboxKeepDownload) + self.formLayout.addWidget(self.btnGrabCores) + + # Set dialog layout + self.setLayout(self.formLayout) + + def auto_detect(self): + if self.chkboxPlatformDetect.isChecked(): + self.cmbboxPlatform.setEnabled(False) + self.cmbboxArchitecture.setEnabled(False) + else: + self.cmbboxPlatform.setEnabled(True) + self.cmbboxArchitecture.setEnabled(True) + + def auto_location(self): + if self.chkboxLocationDetect.isChecked(): + self.leditCoreLocation.setEnabled(False) + self.btnCoreLocation.setEnabled(False) + else: + self.leditCoreLocation.setEnabled(True) + self.btnCoreLocation.setEnabled(True) + + def choose_location(self): + directory = QFileDialog.getExistingDirectory( + self, 'Choose Target Location', os.path.expanduser('~')) + + self.leditCoreLocation.insert(directory) + + def update_log(self, _info): + self.teditLog.insertPlainText('{}\n'.format(_info)) + + # Auto scrolling on log UI + self.teditLog.moveCursor(QTextCursor.End) + + def lock_ui(self, _lock): + # Cycle through each widget and disable it except for log UI + widgets = (self.formLayout.itemAt(i).widget() for i in range(self.formLayout.count())) + for widget in widgets: + if isinstance(widget, QTextEdit): + pass + else: + widget.setDisabled(_lock) + # Have to run these to make sure only the correct things unlock after grab thread + self.auto_detect() + self.auto_location() + + + def grab_cores(self): + """ Where the magic happens """ + if not self.chkboxKeepDownload.isChecked(): + self.clean_up() + + target_platform = self.get_platform() + architecture = self.get_architecture() + location = self.get_location() + + self.grab = GrabThread(target_platform, architecture, location) + self.grab.add_to_log.connect(self.update_log) + self.grab.lock.connect(self.lock_ui) + self.grab.start() + + def get_platform(self): + """ Gets the Platform and Architecture if not supplied """ + + if not self.chkboxPlatformDetect.isChecked(): + if self.cmbboxPlatform.currentText() == 'macOS': + return 'apple/osx' # macOS + else: + return self.cmbboxPlatform.currentText().lower() + else: + if platform.system() == 'Linux': + return 'linux' + elif platform.system() == 'Darwin': # macOS + return 'apple/osx' + elif platform.system() == 'Windows' or 'MSYS_NT' in platform.system(): # Checks for MSYS environment as well + return 'windows' + else: + msgBox = QMessageBox.warning(self, 'Error', 'Platform not found or supported!', QMessageBox.Ok) + msgBox.exec_() + + def get_architecture(self): + """ Gets the Platform and Architecture if not supplied """ + + if '64' in platform.architecture()[0]: + return 'x86_64' + + elif '32' in platform.architecture()[0]: + return 'x86' + else: + msgBox = QMessageBox.warning(self, 'Error', 'Architecture not found or supported', QMessageBox.Ok) + msgBox.exec_() + + def get_location(self): + if not self.chkboxLocationDetect.isChecked(): + return self.leditCoreLocation.text() + else: + return CORE_LOCATION[self.get_platform()] + + def clean_up(self): + """ Removes all the downloaded files """ + if os.listdir('cores'): + rmtree('cores/') + + +if __name__ == '__main__': + # Create the Qt Application + app = QApplication(sys.argv) + # Create and show the form + form = Form() + form.setFixedWidth(438) # So all text on the log UI stays on one line + form.show() + # Run the main Qt loop + sys.exit(app.exec_()) diff --git a/screenshots/custom_location.gif b/screenshots/custom_location.gif new file mode 100644 index 0000000..1694cc8 Binary files /dev/null and b/screenshots/custom_location.gif differ diff --git a/screenshots/custom_platform.gif b/screenshots/custom_platform.gif new file mode 100644 index 0000000..2ad7539 Binary files /dev/null and b/screenshots/custom_platform.gif differ diff --git a/screenshots/grab_cores.gif b/screenshots/grab_cores.gif new file mode 100644 index 0000000..e01994c Binary files /dev/null and b/screenshots/grab_cores.gif differ