#!/usr/bin/env python3 import os import re import shlex import shutil from configparser import RawConfigParser from pathlib import Path from subprocess import run from types import SimpleNamespace try: from PyQt6.QtCore import * # type: ignore from PyQt6.QtGui import * # type: ignore from PyQt6.QtWidgets import * # type: ignore except ModuleNotFoundError: from PyQt5.QtCore import * # type: ignore from PyQt5.QtGui import * # type: ignore from PyQt5.QtWidgets import * # type: ignore settings = QSettings('PPGL', 'PortProtonGamesLib') g = SimpleNamespace(locale = '') class MainWindow(QMainWindow): def __init__(self): super().__init__() self.resize(QSize(800, 600)) geometry = settings.value('geometry_main') if geometry: self.restoreGeometry(geometry) shortcut = RawConfigParser() shortcut.read(os.getenv('HOME') + '/.local/share/applications/PortProton.desktop') scripts_dir = shortcut.get('Desktop Entry', 'Path', fallback=os.getenv('HOME') + '/.local/share/PortWINE/PortProton/data/scripts') if not scripts_dir or not Path(scripts_dir).is_dir(): QMessageBox.critical(self, 'Error', 'Can not find installed PortProton') exit(1) g.scripts_dir = scripts_dir.rstrip('/') g.pp_icon = shortcut.get('Desktop Entry', 'Icon', fallback='/usr/share/pixmaps/portproton.png') pp_icon = QIcon(g.pp_icon) self.setWindowIcon(pp_icon) self.setWindowTitle('PortProton games library') g.base_dir = str(Path(scripts_dir + '/../..').resolve()) g.install_pfx = g.base_dir + '/data/prefixes/INSTALL' g.shortcuts_dir = g.base_dir + '/shortcuts' g.games_dir = g.base_dir + '/games' loc_path = Path(g.base_dir + '/data/tmp/PortProton_loc') if loc_path.exists(): g.locale = loc_path.read_text().strip() Path(g.shortcuts_dir).mkdir(parents=True, exist_ok=True) Path(g.games_dir).mkdir(parents=True, exist_ok=True) sep = QFrame(self) sep.setFrameShape(QFrame.Shape.VLine) sep.setFrameShadow(QFrame.Shadow.Sunken) self._status_size = QLabel(self) self._status_dir = QLabel(self) self.statusBar().setVisible(False) self.statusBar().addWidget(self._status_dir, 1) self.statusBar().addWidget(sep) self.statusBar().addWidget(self._status_size) self.game_list = GameList(self) self.setCentralWidget(self.game_list) self.toolbar = self.addToolBar('Main') self.toolbar.setIconSize(QSize(32, 32)) self.toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) self.toolbar.setMovable(False) action = QAction(self.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogNewFolder), _tr('Install new game'), self) action.triggered.connect(self.install_game) self.toolbar.addAction(action) action = QAction(self.style().standardIcon(QStyle.StandardPixmap.SP_FileLinkIcon), _tr('Add game entry'), self) action.triggered.connect(self.add_game) self.toolbar.addAction(action) action = QAction(self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload), _tr('Reload list'), self) action.triggered.connect(self.reload_list) self.toolbar.addAction(action) action = QAction(self.style().standardIcon(QStyle.StandardPixmap.SP_TrashIcon), _tr('Drop install prefix'), self) action.triggered.connect(self.drop_prefix) self.toolbar.addAction(action) spacer = QWidget(self) spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) self.toolbar.addWidget(spacer) action = QAction(pp_icon, 'PortProton', self) action.triggered.connect(self.run_pp) self.toolbar.addAction(action) def install_game(self): InstallGame(self) def add_game(self): InstallGame(self, False) def reload_list(self): self.game_list.reload() def drop_prefix(self): res = QMessageBox.question(self, _tr('Are you shure ?'), _tr('Do you really want to remove
{0} ?', g.install_pfx)) if res == QMessageBox.StandardButton.Yes: shutil.rmtree(g.install_pfx, True) def run_pp(self): self.setDisabled(True) app.processEvents() run([g.scripts_dir + '/start.sh']) self.setDisabled(False) def set_status(self, item): self.statusBar().setVisible(bool(item)) if item: self._status_size.setText('Size: ' + item.dir_size_human) self._status_dir.setText(' ' + item.game_dir) def closeEvent(self, event): geometry = self.saveGeometry() settings.setValue('geometry_main', geometry) super().closeEvent(event) class LoadListThread(QThread): completed = pyqtSignal(list) def __init__(self, parent, install_dir): super().__init__(parent) self.install_dir = install_dir def run(self): exe_list = list(Path(self.install_dir).glob('**/*.exe')) self.completed.emit(exe_list) class InstallGame(QDialog): def __init__(self, parent, installing=True): super().__init__(parent) self._installing = installing self.install_dir = g.install_pfx + '/drive_c/Games' if installing else g.games_dir self._exe_list_widget = QListWidget(self) self._exe_list_widget.setIconSize(QSize(16, 16)) self._exe_list_widget.itemDoubleClicked.connect(self._handleDoubleClick) layout = QVBoxLayout() layout.addWidget(self._exe_list_widget) self._pbar = QProgressBar(self) self._pbar.setMaximum(0) layout.addWidget(self._pbar) thread = LoadListThread(self, self.install_dir) thread.completed.connect(self.load) thread.start() if self._installing: setup_btn = QPushButton(self) setup_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogStart)) setup_btn.setText(_tr('Run another setup')) setup_btn.clicked.connect(self._runSetup) layout.addWidget(setup_btn) self.setLayout(layout) self.resize(400, 300) self.setModal(True) self.setWindowTitle(_tr('Select game exe file')) geometry = settings.value('geometry_install') if geometry: self.restoreGeometry(geometry) self.show() def load(self, exe_list): if self._installing and len(exe_list) == 0: self._runSetup() exe_list = list(Path(self.install_dir).glob('**/*.exe')) if len(exe_list) == 0: return self.close() def render_list(): pixmap = QPixmap(16, 16) pixmap.fill(Qt.GlobalColor.transparent) empty_icon = QIcon(pixmap) for exe in sorted(exe_list): ico_file = str(exe) + '.ico' item = QListWidgetItem(self._exe_list_widget) item.setText(str(exe)[len(self.install_dir)+1:]) try: if not Path(ico_file).exists(): run(['wrestool', '-x', '-t14', '-o', ico_file, exe], capture_output=True) item.setIcon(QIcon(ico_file)) except Exception: pass if item.icon().pixmap(16, 16).isNull(): item.setIcon(empty_icon) self._exe_list_widget.addItem(item) self._pbar.setVisible(False) thread = QThread(self) thread.run = render_list thread.start() def _runSetup(self): downloads_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DownloadLocation) exe_file, _ = QFileDialog.getOpenFileName(self, caption=_tr('Choose setup file'), filter='Exe files (*.exe)', directory=downloads_dir) if not exe_file: return ppdb = shlex.quote(exe_file + '.ppdb') script = f""" mkdir -p {shlex.quote(g.install_pfx + '/drive_c/Games')} echo ' export PP_VULKAN_USE=1 export PP_GUI_DISABLED_CS=1 export PP_PREFIX_NAME=INSTALL export PP_DLL_INSTALL=mfc42 ' > {ppdb} {shlex.quote(g.scripts_dir + '/start.sh')} {shlex.quote(exe_file)} rm -f {ppdb} """ self.setDisabled(True) app.processEvents() run(['bash', '-c', script]) self.setDisabled(False) def _handleDoubleClick(self, item): game_dir = item.text().split('/')[0] dlg = QInputDialog(self) dlg.setWindowTitle(_tr('Please enter game entry name')) dlg.setLabelText(_tr('New game entry')) dlg.setTextValue(game_dir) dlg.resize(300, 0) ok = dlg.exec() shortcut_name = dlg.textValue() if not ok or not shortcut_name: return file_name = re.sub(r'[<>:/\\|?*]', '_', shortcut_name) shortcut = f"{g.shortcuts_dir}/{file_name}.desktop" if Path(shortcut).exists(): res = QMessageBox.question(self, _tr('Shortcut already exists'), _tr('Shortcut {0} already exists. Overwrite ?', file_name)) if res != QMessageBox.StandardButton.Yes: return src_dir = self.install_dir + '/' + game_dir dst_dir = g.games_dir + '/' + game_dir exe_file = shlex.quote(g.games_dir + '/' + item.text()) ppdb = shlex.quote(g.games_dir + '/' + item.text()) + '.ppdb' self.setDisabled(True) if self._installing and Path(dst_dir).exists(): res = QMessageBox.question(self, _tr('Dir already exists'), _tr('Dir {0} already exists. Overwrite ?', game_dir)) if res != QMessageBox.StandardButton.Yes: return if self._installing: os.rename(src_dir, dst_dir) script = f""" export INSTALLING_PORT=1 export portwine_exe={exe_file} cd {shlex.quote(g.scripts_dir)} . {shlex.quote(g.scripts_dir + '/runlib')} pp_create_gui_png pp_init_db [ -f {ppdb} ] && . {ppdb} echo -e "export PP_VULKAN_USE=${{PP_VULKAN_USE:-1}}\nexport PP_GUI_DISABLED_CS=1" >> {ppdb} """ run(['bash', '-c', script]) icon_path = g.base_dir + '/data/img/' + Path(item.text()).stem + '.png' if not Path(icon_path).exists(): icon_path = g.pp_icon Path(shortcut).write_text(f"""[Desktop Entry] Name={shortcut_name} Exec=env {shlex.quote(g.scripts_dir + '/start.sh')} {exe_file} Type=Application Categories=Game StartupNotify=true Path={shlex.quote(g.scripts_dir)} Icon={icon_path} """, encoding='utf-8') os.chmod(shortcut, 0o755) win.reload_list() self.close() def closeEvent(self, event): geometry = self.saveGeometry() settings.setValue('geometry_install', geometry) super().closeEvent(event) class GameList(QListWidget): def __init__(self, parent): super().__init__(parent) self.itemActivated.connect(self.runGame) self.currentItemChanged.connect(self.selectItem) self.setViewMode(QListWidget.ViewMode.IconMode) self.setResizeMode(QListWidget.ResizeMode.Adjust) self.setIconSize(QSize(64, 64)) self.setWordWrap(True) self.setSpacing(3) self.reload() def reload(self): self.clear() shortcuts = list(Path(g.shortcuts_dir).glob('*.desktop')) shortcuts += list(Path(g.base_dir).glob('*.desktop')) for shortcut in shortcuts: try: item = GameItem(self, shortcut) self.addItem(item) except Exception: pass self.sortItems() self.setCurrentIndex(QModelIndex()) def runGame(self, item): win.setDisabled(True) app.processEvents() run(['bash', '-c', item.get('Exec')]) win.setDisabled(False) def selectItem(self, item): win.set_status(item) def contextMenuEvent(self, event): selected = self.selectedItems() if len(selected) == 0: return selected = selected[0] menu = QMenu(self) desktop = menu.addAction(self.style().standardIcon(QStyle.StandardPixmap.SP_DesktopIcon), _tr('Add to desktop')) restore_gui = menu.addAction(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogResetButton), _tr('Restore PortProton GUI')) remove = menu.addAction(self.style().standardIcon(QStyle.StandardPixmap.SP_TrashIcon), _tr('Remove game entry')) uninstall = menu.addAction(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogCloseButton), _tr('Uninstall game')) if not selected.game_dir.startswith(g.games_dir): uninstall.setVisible(False) action = menu.exec(self.mapToGlobal(event.pos())) desktop_shortcut = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation) + '/' + Path(selected.desktop_file).name if action == desktop: if Path(desktop_shortcut).exists(): res = QMessageBox.question(self, _tr('Shortcut already exists'), _tr('Shortcut {0} already exists. Overwrite ?', desktop_shortcut)) if res != QMessageBox.StandardButton.Yes: return shutil.copy(selected.desktop_file, desktop_shortcut) if action == restore_gui: ppdb = shlex.split(selected.get('Exec'))[-1] + '.ppdb' if not Path(ppdb).exists(): return with open(ppdb, 'r') as read: with open(ppdb + '.new', 'w') as write: while (line := read.readline()): if 'PP_GUI_DISABLED_CS' not in line: write.write(line) os.rename(ppdb + '.new', ppdb) if action == remove: Path(desktop_shortcut).unlink(True) Path(selected.desktop_file).unlink(True) Path(selected.get('Icon')).unlink(True) self.reload() if action == uninstall: res = QMessageBox.question(self, _tr('Are you shure ?'), _tr('Do you really want to uninstall {0}
located in "{1}" ?', selected.get('Name'), selected.game_dir) ) if res != QMessageBox.StandardButton.Yes: return Path(desktop_shortcut).unlink(True) Path(selected.desktop_file).unlink(True) Path(selected.get('Icon')).unlink(True) if selected.game_dir.startswith(g.games_dir): shutil.rmtree(selected.game_dir, True) self.reload() def human_size(num): if not num: return "-" for unit in ["", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"]: if abs(num) < 1024.0: return f"{num:.2f} {unit}B" num /= 1024.0 return f"{num:.2f} YiB" class GameItem(QListWidgetItem): def __init__(self, parent, desktop_file): self.desktop_file = desktop_file self.config = RawConfigParser() self.config.read(desktop_file) text = self.get('Name', Path(desktop_file).stem) if not self.get('Exec') or text == 'PortProton': raise Exception('Validation fail') self.game_dir = shlex.split(self.get('Exec'))[-1] if self.game_dir.startswith(g.games_dir): self.game_dir = g.games_dir + '/' + self.game_dir[len(g.games_dir)+1:].split('/')[0] else: self.game_dir = str(Path(self.game_dir).parent) if self.game_dir == '.': raise Exception('Can not determine game dir') super().__init__(parent) self.setToolTip(text) self.setText(text) icon_path = self.get('Icon') if Path(self.get('Icon')).exists() else g.pp_icon qicon = QIcon(icon_path) self.setIcon(qicon) self.setTextAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop) self.setSizeHint(QSize(100, 105)) self._set_dir_size(None) dir_size_cache = self.game_dir + '/.size' if Path(dir_size_cache).exists(): self._set_dir_size(int(Path(dir_size_cache).read_text())) else: def calc_dir_size(): if not Path(self.game_dir).exists(): return dir_size = sum(p.stat(follow_symlinks=False).st_size for p in Path(self.game_dir).rglob('*')) self._set_dir_size(dir_size) Path(dir_size_cache).write_text(str(dir_size)) thread = QThread(parent) thread.run = calc_dir_size thread.start() def get(self, name, fallback=None): return self.config.get('Desktop Entry', name, fallback=fallback) def _set_dir_size(self, size): self.dir_size = size self.dir_size_human = human_size(size) import signal signal.signal(signal.SIGINT, signal.SIG_DFL) lang = { 'RUS': { 'Install new game': 'Установить игру', 'Add game entry': 'Добавить в список', 'Reload list': 'Обновить список', 'Drop install prefix': 'Удалить установочный префикс', 'Are you shure ?': 'Вы уверены ?', 'Do you really want to remove
{0} ?': 'Вы действительно хотите удалить
{0} ?', 'Run another setup': 'Запустить установку', 'Select game exe file': 'Выберите exe файл игры', 'Choose setup file': 'Выберите установочный файл', 'Please enter game entry name': 'Введите название игры', 'New game entry': 'Название игры', 'Shortcut already exists': 'Ярлык уже существует', 'Shortcut {0} already exists. Overwrite ?': 'Ярлык {0} уже существует. Перезаписать ?', 'Dir already exists': 'Директория уже существует', 'Dir {0} already exists. Overwrite ?': 'Директория {0} уже существует. Перезаписать ?', 'Add to desktop': 'Добавить на рабочий стол', 'Restore PortProton GUI': 'Восстановить PortProton GUI', 'Remove game entry': 'Убрать из списка', 'Uninstall game': 'Удалить игру', 'Do you really want to uninstall {0}
located in "{1}" ?': 'Вы действительно хотите удалить {0}
расположеную в "{1}" ?' } } def _tr(text, *fmt): res = lang.get(g.locale, {}).get(text, text) if fmt: res = res.format(*fmt) return res app = QApplication([]) win = MainWindow() win.show() app.exec()