diff --git a/build-aux/PKGBUILD b/build-aux/PKGBUILD index ba1ff641..b66f1d4e 100644 --- a/build-aux/PKGBUILD +++ b/build-aux/PKGBUILD @@ -6,7 +6,7 @@ arch=('any') url="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt" license=('GPL-3.0') depends=('python-requests' 'python-babel' 'python-evdev' 'python-pygame' 'python-orjson' - 'python-psutil' 'python-tqdm' 'python-vdf' 'python-libarchive-c' 'pyside6' 'python-rapidfuzz' 'python-pefile' 'python-pillow' 'perl-image-exiftool' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'jq' 'file' 'findutils' 'gawk' 'grep' 'tar' 'xz' 'zstd' 'gzip' 'unrar' 'qt6-svg' 'qt6-imageformats' 'pciutils' 'mesa-utils' 'vulkan-icd-loader' 'procps-ng' 'psmisc' 'squashfs-tools' '7zip' 'python-dbus-fast') + 'python-psutil' 'python-tqdm' 'python-vdf' 'python-libarchive-c' 'pyside6' 'python-rapidfuzz' 'python-pefile' 'python-pillow' 'perl-image-exiftool' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'jq' 'file' 'findutils' 'gawk' 'grep' 'tar' 'xz' 'zstd' 'gzip' 'unrar' 'qt6-svg' 'qt6-imageformats' 'pciutils' 'mesa-utils' 'vulkan-icd-loader' 'procps-ng' 'psmisc' '7zip' 'python-dbus-fast') makedepends=('meson' 'ninja' 'vulkan-headers' 'gettext') source=("git+https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt#tag=v$pkgver") sha256sums=('SKIP') @@ -24,6 +24,7 @@ package_portprotonqt() { 'upower: System tab bluetooth battery level' 'libpulse: System tab audio volume/output management' 'python-qrcode: Wi-Fi QR code generation' + 'squashfs-tools: for legacy PortProton prefix backup support' ) DESTDIR="$pkgdir" meson install -C build --skip-subprojects diff --git a/build-aux/PKGBUILD-git b/build-aux/PKGBUILD-git index 61bd7e86..f1b6af48 100644 --- a/build-aux/PKGBUILD-git +++ b/build-aux/PKGBUILD-git @@ -6,7 +6,7 @@ arch=('any') url="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt" license=('GPL-3.0') depends=('python-requests' 'python-babel' 'python-evdev' 'python-pygame' 'python-orjson' - 'python-psutil' 'python-tqdm' 'python-vdf' 'python-libarchive-c' 'pyside6' 'python-rapidfuzz' 'python-pefile' 'python-pillow' 'perl-image-exiftool' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'jq' 'file' 'findutils' 'gawk' 'grep' 'tar' 'xz' 'zstd' 'gzip' 'unrar' 'qt6-svg' 'qt6-imageformats' 'pciutils' 'mesa-utils' 'vulkan-icd-loader' 'procps-ng' 'psmisc' 'squashfs-tools' '7zip' 'python-dbus-fast') + 'python-psutil' 'python-tqdm' 'python-vdf' 'python-libarchive-c' 'pyside6' 'python-rapidfuzz' 'python-pefile' 'python-pillow' 'perl-image-exiftool' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'jq' 'file' 'findutils' 'gawk' 'grep' 'tar' 'xz' 'zstd' 'gzip' 'unrar' 'qt6-svg' 'qt6-imageformats' 'pciutils' 'mesa-utils' 'vulkan-icd-loader' 'procps-ng' 'psmisc' '7zip' 'python-dbus-fast') makedepends=('meson' 'ninja' 'vulkan-headers' 'gettext') source=("git+https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt.git") sha256sums=('SKIP') @@ -29,6 +29,7 @@ package_portprotonqt-git() { 'upower: System tab bluetooth battery level' 'libpulse: System tab audio volume/output management' 'python-qrcode: Wi-Fi QR code generation' + 'squashfs-tools: for legacy PortProton prefix backup support' ) DESTDIR="$pkgdir" meson install -C build --skip-subprojects diff --git a/build-aux/fedora-git.spec b/build-aux/fedora-git.spec index 68ff3a1a..5e3b98e9 100644 --- a/build-aux/fedora-git.spec +++ b/build-aux/fedora-git.spec @@ -63,7 +63,6 @@ Requires: pciutils Requires: vulkan-loader Requires: procps-ng Requires: psmisc -Requires: squashfs-tools Requires: 7zip Requires: python3-dbus-fast @@ -74,6 +73,9 @@ Recommends: upower Recommends: pulseaudio-utils Recommends: python3-qrcode +# For legacy PortProton prefix backup support +Recommends: squashfs-tools + %description A modern and intuitive interface for managing and launching games from PortProton and Steam. Combines libraries in one place and simplifies running Windows games on Linux. diff --git a/build-aux/fedora.spec b/build-aux/fedora.spec index b62b1e09..e9136ba4 100644 --- a/build-aux/fedora.spec +++ b/build-aux/fedora.spec @@ -60,7 +60,6 @@ Requires: pciutils Requires: vulkan-loader Requires: procps-ng Requires: psmisc -Requires: squashfs-tools Requires: 7zip Requires: python3-dbus-fast @@ -71,6 +70,9 @@ Recommends: upower Recommends: pulseaudio-utils Recommends: python3-qrcode +# For legacy PortProton prefix backup support +Recommends: squashfs-tools + %description A modern and intuitive interface for managing and launching games from PortProton and Steam. Combines libraries in one place and simplifies running Windows games on Linux. diff --git a/build-aux/share/mime/packages/ru.linux_gaming.PortProtonQt.xml b/build-aux/share/mime/packages/ru.linux_gaming.PortProtonQt.xml index fa36d5c9..45470f26 100644 --- a/build-aux/share/mime/packages/ru.linux_gaming.PortProtonQt.xml +++ b/build-aux/share/mime/packages/ru.linux_gaming.PortProtonQt.xml @@ -4,6 +4,5 @@ PortProton prefix backup Резервная копия префикса PortProton - diff --git a/build-aux/share/portproton/scripts/functions_helper b/build-aux/share/portproton/scripts/functions_helper index 24c64640..2419dc30 100755 --- a/build-aux/share/portproton/scripts/functions_helper +++ b/build-aux/share/portproton/scripts/functions_helper @@ -4668,65 +4668,25 @@ pw_create_prefix_backup_cli () { PW_PREFIX_NAME="$1" PW_PREFIX_TO_BACKUP="$2" - if [[ -n "$(grep "/${PW_PREFIX_NAME}/" "${PORT_DATA_PATH}"/*.desktop )" ]] ; then - try_remove_file "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/.create_shortcut" - grep "/${PW_PREFIX_NAME}/" "${PORT_DATA_PATH}"/*.desktop | awk -F"/${PW_PREFIX_NAME}/" '{print $2}' \ - | awk -F\" '{print $1}' > "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/.create_shortcut" - fi - - unset PW_SANDBOX_HOME_PATH - pw_init_runtime - if check_flatpak; then - TMP_ALL_PATH="" - else - TMP_ALL_PATH="LD_LIBRARY_PATH=\"${PW_LD_LIBRARY_PATH}\"" - fi - - chmod -R u+w "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}" - - echo "#!/usr/bin/env bash - ${TMP_ALL_PATH} \"${PW_PLUGINS_PATH}/portable/bin/mksquashfs\" \"${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}\" \ - \"${PW_PREFIX_TO_BACKUP}/${PW_PREFIX_NAME}.ppack.part\" -comp zstd \ - || rm -f \"${PW_PREFIX_TO_BACKUP}/${PW_PREFIX_NAME}.ppack.part\" - " > "${PORT_WINE_TMP_PATH}/pp_pfx_backup.sh" - - chmod u+x "${PORT_WINE_TMP_PATH}/pp_pfx_backup.sh" - - ${pw_runtime} ${PW_TERM} "${PORT_WINE_TMP_PATH}/pp_pfx_backup.sh" - - try_remove_file "${PORT_WINE_TMP_PATH}/pp_pfx_backup.sh" - - if [[ -f "${PW_PREFIX_TO_BACKUP}/${PW_PREFIX_NAME}.ppack.part" ]]; then - mv -f "${PW_PREFIX_TO_BACKUP}/${PW_PREFIX_NAME}.ppack.part" \ - "${PW_PREFIX_TO_BACKUP}/${PW_PREFIX_NAME}.ppack" - return 0 - else - return 1 - fi + python3 -m portprotonqt.scripts_utils.prefix_backup --port-data-path "${PORT_DATA_PATH}" \ + backup "${PW_PREFIX_NAME}" "${PW_PREFIX_TO_BACKUP}" + return $? } -pw_unpack_prefix () { - PW_BACKUP_FILE="$1" +pw_is_legacy_prefix_backup () { + [[ "$(head -c 4 "$1" 2>/dev/null)" == "hsqs" ]] +} + +pw_unpack_legacy_prefix () { + PW_PREFIX_NAME=$(basename "${PW_BACKUP_FILE}") + PW_PREFIX_NAME="${PW_PREFIX_NAME%.[Pp][Pp][Aa][Cc][Kk]}" unset PW_SANDBOX_HOME_PATH pw_init_runtime - if check_flatpak; then - TMP_ALL_PATH="" - else - TMP_ALL_PATH="LD_LIBRARY_PATH=\"${PW_LD_LIBRARY_PATH}\"" - fi - - if check_selinux; then - NO_XATTRS_NEED="-no-xattrs" - else - NO_XATTRS_NEED="" - fi - - PW_PREFIX_NAME=$(basename "${PW_BACKUP_FILE^^}" .PPACK) - + try_remove_file "${PORT_WINE_TMP_PATH}/pp_pfx_unpack_error" echo "#!/usr/bin/env bash - ${TMP_ALL_PATH} \"${PW_PLUGINS_PATH}/portable/bin/unsquashfs\" $NO_XATTRS_NEED -f -d \ + unsquashfs -no-xattrs -f -d \ \"${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}\" \"$PW_BACKUP_FILE\" \ || echo \"ERROR\" > \"${PORT_WINE_TMP_PATH}/pp_pfx_unpack_error\" " > "${PORT_WINE_TMP_PATH}/pp_pfx_unpack.sh" @@ -4738,15 +4698,31 @@ pw_unpack_prefix () { try_remove_file "${PORT_WINE_TMP_PATH}/pp_pfx_unpack_error" try_remove_file "${PORT_WINE_TMP_PATH}/pp_pfx_unpack.sh" return 1 - else - try_remove_file "${PORT_WINE_TMP_PATH}/pp_pfx_unpack.sh" - if [[ -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/.create_shortcut" ]] ; then - while read -r line - do - export PW_EXE_FILE="$PORT_DATA_PATH/data/prefixes/$PW_PREFIX_NAME/$line" - portwine_output_yad_shortcut - done < "$PORT_DATA_PATH/data/prefixes/$PW_PREFIX_NAME/.create_shortcut" - fi - return 0 fi + try_remove_file "${PORT_WINE_TMP_PATH}/pp_pfx_unpack_error" + try_remove_file "${PORT_WINE_TMP_PATH}/pp_pfx_unpack.sh" + if [[ -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/.create_shortcut" ]] ; then + while read -r line + do + export PW_EXE_FILE="$PORT_DATA_PATH/data/prefixes/$PW_PREFIX_NAME/$line" + portwine_output_yad_shortcut + done < "$PORT_DATA_PATH/data/prefixes/$PW_PREFIX_NAME/.create_shortcut" + fi + return 0 +} + +pw_unpack_prefix () { + PW_BACKUP_FILE="$1" + + PW_PREFIX_NAME=$(basename "${PW_BACKUP_FILE}") + PW_PREFIX_NAME="${PW_PREFIX_NAME%.[Pp][Pp][Aa][Cc][Kk]}" + + if pw_is_legacy_prefix_backup "${PW_BACKUP_FILE}" ; then + pw_unpack_legacy_prefix + return $? + fi + + python3 -m portprotonqt.scripts_utils.prefix_backup --port-data-path "${PORT_DATA_PATH}" \ + restore "${PW_BACKUP_FILE}" + return $? } diff --git a/portprotonqt/app.py b/portprotonqt/app.py index 22b42480..18e367e6 100644 --- a/portprotonqt/app.py +++ b/portprotonqt/app.py @@ -3,6 +3,7 @@ import sys import os import importlib import subprocess +from urllib.parse import quote, unquote from logging import Logger __app_id__ = "ru.linux_gaming.PortProtonQt" @@ -203,19 +204,9 @@ def main(): if start_sh is None: return - if parsed_args.create_backup: - setup_logger(parsed_args.debug_level) - prefix_name, backup_dir = parsed_args.create_backup - backup_exit_code = create_prefix_backup(start_sh, prefix_name, backup_dir) - sys.exit(backup_exit_code) - if parsed_args.restore_prefix and not parsed_args.file_or_url: setup_logger(parsed_args.debug_level) sys.exit(1) - if is_restore_prefix_request(parsed_args): - setup_logger(parsed_args.debug_level) - restore_exit_code = restore_prefix_backup(start_sh, parsed_args.file_or_url) - sys.exit(restore_exit_code) # Handle Steam compatibility mode - launch game directly without GUI. if is_steam_compat: @@ -257,6 +248,8 @@ def main(): fullscreen = args.fullscreen or display_config.get_fullscreen() ipc_message = "show:fullscreen" if fullscreen else "show" + backup_request = None + restore_prefix_path = None resolution_from_args = None if args.resolution: resolution_from_args = parse_resolution(args.resolution) @@ -266,9 +259,16 @@ def main(): window_config.set_geometry(resolution_from_args[0], resolution_from_args[1]) logger.info("Saved window resolution: %sx%s", resolution_from_args[0], resolution_from_args[1]) ipc_message = "noop" - if is_silent_launch and args.file_or_url and is_exe_file(args.file_or_url): + if args.create_backup: + prefix_name, backup_dir = args.create_backup + backup_request = (prefix_name, os.path.abspath(os.path.expanduser(backup_dir))) + ipc_message = "backup:{}:{}".format(quote(prefix_name, safe=""), quote(backup_request[1], safe="")) + elif is_silent_launch and args.file_or_url and is_exe_file(args.file_or_url): run_silent_tray(app, start_sh, normalize_launch_path(args.file_or_url)) sys.exit(app.exec()) + elif is_restore_prefix_request(args): + restore_prefix_path = normalize_launch_path(args.file_or_url) + ipc_message = f"restore:{quote(restore_prefix_path, safe='')}" elif args.file_or_url and is_launch_file(args.file_or_url): ipc_message = f"open:{normalize_launch_path(args.file_or_url)}" @@ -319,7 +319,7 @@ def main(): logger.warning("Failed to persist PORT_DATA_PATH in PortProtonQt config") # Check if we have a portproton:// URL or launch file to handle - if args.file_or_url: + if args.file_or_url and not restore_prefix_path: if is_portproton_url(args.file_or_url): # Parse the portproton:// URL to get the full download URL download_url = parse_portproton_url(args.file_or_url) @@ -366,6 +366,14 @@ def main(): def handle_launch_exe(): window.handle_launch_exe(exe_path) QTimer.singleShot(0, handle_launch_exe) + elif backup_request: + def handle_create_backup(): + window._perform_backup(backup_request[1], backup_request[0]) + QTimer.singleShot(0, handle_create_backup) + elif restore_prefix_path: + def handle_restore_prefix(): + window._perform_restore(restore_prefix_path) + QTimer.singleShot(0, handle_restore_prefix) # --- Handle incoming connections --- def handle_new_connection(): @@ -380,7 +388,12 @@ def main(): def restore_window(): try: - if msg.startswith("show") or msg.startswith("open:"): + if ( + msg.startswith("show") + or msg.startswith("open:") + or msg.startswith("restore:") + or msg.startswith("backup:") + ): # Ensure the window is visible and not minimized window.setWindowState(window.windowState() & ~Qt.WindowState.WindowMinimized) window.show() @@ -407,6 +420,22 @@ def main(): window.handle_launch_exe(launch_path) else: logger.warning("Invalid launch file via IPC: %s", launch_path) + elif msg.startswith("restore:"): + backup_path = unquote(msg[8:].strip()) + if backup_path and is_prefix_backup_file(backup_path): + logger.info("Restoring prefix backup via IPC: %s", backup_path) + window._perform_restore(backup_path) + else: + logger.warning("Invalid prefix backup via IPC: %s", backup_path) + elif msg.startswith("backup:"): + parts = msg.split(":", 2) + if len(parts) == 3: + prefix_name = unquote(parts[1]) + backup_dir = unquote(parts[2]) + logger.info("Creating prefix backup via IPC: %s", prefix_name) + window._perform_backup(backup_dir, prefix_name) + else: + logger.warning("Invalid prefix backup request via IPC: %s", msg) except Exception as e: logger.warning(f"Failed to restore window: {e}") @@ -424,6 +453,8 @@ def main(): and not args.fullscreen and window_resolution is None and exe_path is None + and backup_request is None + and restore_prefix_path is None ) if launch_minimized: logger.info("Launching in tray") diff --git a/portprotonqt/dialogs/file_explorer.py b/portprotonqt/dialogs/file_explorer.py index 2041e82e..ddfc62df 100644 --- a/portprotonqt/dialogs/file_explorer.py +++ b/portprotonqt/dialogs/file_explorer.py @@ -588,7 +588,10 @@ class FileExplorer(DraggableDialog): item = QListWidgetItem(f) mime_type = self.mime_db.mimeTypeForFile(file_path) if ( - mime_type.name() in ("application/vnd.squashfs", "application/x-squashfs") + f.lower().endswith(".ppack") + or mime_type.name() == "application/x-portproton-prefix-backup" + or mime_type.inherits("application/x-portproton-prefix-backup") + or mime_type.name() in ("application/vnd.squashfs", "application/x-squashfs") or mime_type.inherits("application/vnd.squashfs") ): ppack_icon = theme_manager.get_icon("ppack") diff --git a/portprotonqt/dialogs/prefix_backup.py b/portprotonqt/dialogs/prefix_backup.py new file mode 100644 index 00000000..45b16d1b --- /dev/null +++ b/portprotonqt/dialogs/prefix_backup.py @@ -0,0 +1,103 @@ +"""Prefix backup progress dialog.""" + +from dataclasses import dataclass + +from PySide6.QtCore import QThread, Signal, Qt +from PySide6.QtWidgets import QDialog, QLabel, QProgressBar, QVBoxLayout + +from portprotonqt.config import ui_config +from portprotonqt.localization import _ +from portprotonqt.scripts_utils.prefix_backup import BackupProgress, create_backup, restore_backup +from portprotonqt.theme_manager import ThemeManager + + +@dataclass(frozen=True) +class PrefixBackupJob: + operation: str + port_data_path: str + source: str + target: str = "" + + +class PrefixBackupThread(QThread): + """Worker thread for prefix backup operations.""" + + progress = Signal(object) + completed = Signal(bool) + + def __init__(self, job: PrefixBackupJob): + super().__init__() + self.job = job + self.success = False + + def _emit_progress(self, progress: BackupProgress) -> None: + self.progress.emit(progress) + + def run(self) -> None: + if self.job.operation == "backup": + exit_code = create_backup( + self.job.port_data_path, + self.job.source, + self.job.target, + self._emit_progress, + ) + else: + exit_code = restore_backup(self.job.port_data_path, self.job.source, self._emit_progress) + self.success = exit_code == 0 + self.completed.emit(self.success) + + +class PrefixBackupDialog(QDialog): + """Dialog showing prefix backup or restore progress.""" + + def __init__(self, parent, worker: PrefixBackupThread, theme=None): + super().__init__(parent) + self.theme = theme if theme else ThemeManager().apply_theme(ui_config.get_theme()) + self.worker = worker + title = _("Prefix backup") if worker.job.operation == "backup" else _("Prefix restore") + self.setWindowTitle(title) + self.setModal(True) + self.setWindowModality(Qt.WindowModality.ApplicationModal) + self.setStyleSheet(self.theme.MESSAGE_BOX_STYLE) + self.resize(740, 230) + + layout = QVBoxLayout(self) + self.title_label = QLabel(title) + self.title_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE) + self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(self.title_label) + + self.progress_bar = QProgressBar() + self.progress_bar.setStyleSheet(self.theme.PROGRESS_BAR_STYLE) + self.progress_bar.setRange(0, 100) + layout.addWidget(self.progress_bar) + + self.file_label = QLabel("") + self.file_label.setStyleSheet(self.theme.CONTENT_STYLE) + self.file_label.setWordWrap(True) + layout.addWidget(self.file_label) + + self.stats_label = QLabel("") + self.stats_label.setStyleSheet(self.theme.CONTENT_STYLE) + layout.addWidget(self.stats_label) + + self.worker.progress.connect(self.update_progress) + self.worker.completed.connect(self.finish) + + def start(self) -> None: + self.worker.start() + self.exec() + + def update_progress(self, progress: BackupProgress) -> None: + self.progress_bar.setValue(progress.percent) + self.file_label.setText(_("File: {0}").format(progress.path)) + if progress.files_total: + files_text = _("{0}/{1} files").format(progress.files_done, progress.files_total) + else: + files_text = _("{0} files").format(progress.files_done) + self.stats_label.setText(_("{0}, Speed: {1:.1f} MB/s").format(files_text, progress.speed)) + + def finish(self, success: bool) -> None: + if success: + self.progress_bar.setValue(100) + self.accept() diff --git a/portprotonqt/main_window.py b/portprotonqt/main_window.py index c9f0bb90..cfd57e46 100644 --- a/portprotonqt/main_window.py +++ b/portprotonqt/main_window.py @@ -57,6 +57,8 @@ from portprotonqt.game_library_manager import GameLibraryManager from portprotonqt.virtual_keyboard import VirtualKeyboard from portprotonqt.disc_image_utils import DiscImageManager from portprotonqt.dialogs.proton_manager import show_proton_manager +from portprotonqt.dialogs.prefix_backup import PrefixBackupDialog, PrefixBackupJob, PrefixBackupThread +from portprotonqt.scripts_utils.prefix_backup import is_legacy_squashfs_backup from portprotonqt.tabs.control_hints import MainWindowControlHintsMixin from portprotonqt.tabs.system_tab import MainWindowSystemTabMixin from portprotonqt.tabs.workers import MainWindowWorkersMixin @@ -1985,15 +1987,13 @@ class MainWindow(MainWindowControlHintsMixin, MainWindowSystemTabMixin, MainWind def _perform_backup(self, backup_dir, prefix_name): os.makedirs(backup_dir, exist_ok=True) - if not self.portproton_location or not self.start_sh: + if not self.portproton_location: return - start_sh = self.start_sh - self.backup_process = QProcess(self) - self.backup_process.finished.connect(lambda exitCode: self._on_backup_finished(exitCode)) - cmd = start_sh + ["--backup-prefix", prefix_name, backup_dir] - self.backup_process.start(cmd[0], cmd[1:]) - if not self.backup_process.waitForStarted(): - QMessageBox.warning(self, _("Error"), _("Failed to start backup process.")) + job = PrefixBackupJob("backup", self.portproton_location, prefix_name, backup_dir) + worker = PrefixBackupThread(job) + dialog = PrefixBackupDialog(self, worker, self.theme) + dialog.start() + self._on_backup_finished(0 if worker.success else 1) def load_prefix_backup(self): file_explorer = FileExplorer(self, file_filter='.ppack') @@ -2003,12 +2003,23 @@ class MainWindow(MainWindowControlHintsMixin, MainWindowSystemTabMixin, MainWind def _perform_restore(self, file_path): if not file_path or not os.path.exists(file_path): return - if not self.portproton_location or not self.start_sh: + if not self.portproton_location: + return + if is_legacy_squashfs_backup(file_path): + self._perform_legacy_restore(file_path) + return + job = PrefixBackupJob("restore", self.portproton_location, file_path) + worker = PrefixBackupThread(job) + dialog = PrefixBackupDialog(self, worker, self.theme) + dialog.start() + self._on_restore_finished(0 if worker.success else 1) + + def _perform_legacy_restore(self, file_path: str) -> None: + if not self.start_sh: return - start_sh = self.start_sh self.restore_process = QProcess(self) self.restore_process.finished.connect(lambda exitCode: self._on_restore_finished(exitCode)) - cmd = start_sh + ["--restore-prefix", file_path] + cmd = self.start_sh + ["--restore-prefix", file_path] self.restore_process.start(cmd[0], cmd[1:]) if not self.restore_process.waitForStarted(): QMessageBox.warning(self, _("Error"), _("Failed to start restore process.")) diff --git a/portprotonqt/meson.build b/portprotonqt/meson.build index 09d1cc35..b7c4b3b2 100644 --- a/portprotonqt/meson.build +++ b/portprotonqt/meson.build @@ -65,6 +65,7 @@ install_data( 'dialogs/settings_mangohud.py', 'dialogs/settings_gamescope.py', 'dialogs/proton_manager.py', + 'dialogs/prefix_backup.py', 'dialogs/wine_loader.py', 'dialogs/wine_downloader.py', 'dialogs/wine_extractor.py', @@ -122,6 +123,7 @@ install_data( ['scripts_utils/__init__.py', 'scripts_utils/easyterm.py', 'scripts_utils/idle_inhibit.py', + 'scripts_utils/prefix_backup.py', 'scripts_utils/power_profiles.py', 'scripts_utils/shortcut_tools.py', ], diff --git a/portprotonqt/scripts_utils/prefix_backup.py b/portprotonqt/scripts_utils/prefix_backup.py new file mode 100644 index 00000000..807a82d5 --- /dev/null +++ b/portprotonqt/scripts_utils/prefix_backup.py @@ -0,0 +1,366 @@ +"""Create and restore PortProton prefix backups.""" + +import argparse +import os +import shutil +import stat +import sys +import time +from collections.abc import Callable +from dataclasses import dataclass + +import libarchive +from libarchive.entry import FileType +from libarchive.flags import READDISK_NO_XATTR + +from portprotonqt.logger import get_logger + +logger = get_logger(__name__) +BACKUP_EXTENSION = ".ppack" +ARCHIVE_FORMAT = "pax" +ARCHIVE_FILTER = "zstd" +SQUASHFS_MAGIC = b"hsqs" + + +@dataclass(frozen=True) +class BackupProgress: + percent: int + path: str + files_done: int + files_total: int + speed: float + + +ProgressCallback = Callable[[BackupProgress], None] + + +def _prefix_path(port_data_path: str, prefix_name: str) -> str: + safe_name = os.path.basename(prefix_name) + return os.path.join(port_data_path, "data", "prefixes", safe_name) + + +def _backup_path(backup_dir: str, prefix_name: str) -> tuple[str, str]: + safe_name = os.path.basename(prefix_name) + final_path = os.path.join(backup_dir, f"{safe_name}{BACKUP_EXTENSION}") + return final_path, f"{final_path}.part" + + +def is_legacy_squashfs_backup(backup_file: str) -> bool: + try: + with open(backup_file, "rb") as file: + return file.read(4) == SQUASHFS_MAGIC + except OSError as e: + logger.warning("Failed to read backup header %s: %s", backup_file, e) + return False + + +def _make_user_writable(path: str) -> None: + for root, dirs, files in os.walk(path): + for name in dirs + files: + item_path = os.path.join(root, name) + try: + item_stat = os.lstat(item_path) + if stat.S_ISLNK(item_stat.st_mode): + continue + os.chmod(item_path, item_stat.st_mode | stat.S_IWUSR) + except OSError as e: + logger.warning("Failed to update permissions for %s: %s", item_path, e) + + +def _collect_entries(prefix_dir: str) -> list[tuple[str, str, int]]: + entries = [] + for root, dirs, files in os.walk(prefix_dir): + names = sorted(dirs) + sorted(files) + for name in names: + source_path = os.path.join(root, name) + archive_path = os.path.relpath(source_path, prefix_dir) + try: + item_stat = os.lstat(source_path) + size = item_stat.st_size if stat.S_ISREG(item_stat.st_mode) else 0 + except OSError: + size = 0 + entries.append((source_path, archive_path, size)) + return entries + + +def _archive_symlink(archive, source_path: str, archive_path: str) -> bool: + try: + item_stat = os.lstat(source_path) + link_path = os.readlink(source_path) + except OSError as e: + logger.warning("Skipped symlink %s: %s", source_path, e) + return False + + if not os.path.exists(source_path): + logger.warning("Skipped broken symlink %s -> %s", source_path, link_path) + return False + + archive.add_file_from_memory( + archive_path, + 0, + b"", + filetype=int(FileType.SYMBOLINK_LINK), + permission=stat.S_IMODE(item_stat.st_mode), + linkpath=link_path, + mtime=item_stat.st_mtime, + ) + return True + + +def _archive_entry(archive, source_path: str, archive_path: str) -> bool: + try: + item_stat = os.lstat(source_path) + except OSError as e: + logger.warning("Skipped archive entry %s: %s", source_path, e) + return False + + if stat.S_ISLNK(item_stat.st_mode): + return _archive_symlink(archive, source_path, archive_path) + archive.add_files( + source_path, + flags=READDISK_NO_XATTR, + pathname=archive_path, + recursive=False, + symlink_mode="physical", + ) + return True + + +def _write_shortcut_manifest(port_data_path: str, prefix_name: str) -> None: + shortcuts = [] + marker = f"/{prefix_name}/" + for name in os.listdir(port_data_path): + if not name.endswith(".desktop"): + continue + desktop_path = os.path.join(port_data_path, name) + try: + with open(desktop_path, encoding="utf-8", errors="ignore") as file: + for line in file: + if marker in line: + shortcuts.append(line.split(marker, 1)[1].split('"', 1)[0]) + except OSError as e: + logger.warning("Failed to read desktop file %s: %s", desktop_path, e) + + if not shortcuts: + return + + manifest_path = os.path.join(_prefix_path(port_data_path, prefix_name), ".create_shortcut") + try: + os.remove(manifest_path) + except FileNotFoundError: + pass + with open(manifest_path, "w", encoding="utf-8") as file: + file.write("\n".join(shortcuts)) + file.write("\n") + + +def _emit_progress( + callback: ProgressCallback | None, + progress: BackupProgress, +) -> None: + if callback: + callback(progress) + + +def create_backup( + port_data_path: str, + prefix_name: str, + backup_dir: str, + progress_callback: ProgressCallback | None = None, +) -> int: + prefix_dir = _prefix_path(port_data_path, prefix_name) + final_path, part_path = _backup_path(backup_dir, prefix_name) + if not os.path.isdir(prefix_dir): + logger.error("Prefix directory not found: %s", prefix_dir) + return 1 + + os.makedirs(backup_dir, exist_ok=True) + _write_shortcut_manifest(port_data_path, prefix_name) + _make_user_writable(prefix_dir) + + try: + entries = _collect_entries(prefix_dir) + total_files = len(entries) + total_size = sum(entry[2] for entry in entries) or 1 + processed_size = 0 + start_time = time.monotonic() + with libarchive.file_writer(part_path, ARCHIVE_FORMAT, ARCHIVE_FILTER) as archive: + for index, (source_path, archive_path, size) in enumerate(entries, 1): + _archive_entry(archive, source_path, archive_path) + processed_size += size + elapsed = max(time.monotonic() - start_time, 0.001) + speed = processed_size / (1024 * 1024) / elapsed + percent = min(int((processed_size / total_size) * 100), 99) + progress = BackupProgress(percent, archive_path, index, total_files, speed) + _emit_progress(progress_callback, progress) + os.replace(part_path, final_path) + progress = BackupProgress(100, final_path, total_files, total_files, 0.0) + _emit_progress(progress_callback, progress) + except (libarchive.ArchiveError, OSError) as e: + logger.error("Failed to create prefix backup %s: %s", prefix_name, e) + try: + os.remove(part_path) + except FileNotFoundError: + pass + return 1 + return 0 + + +def _safe_entry_path(target_dir: str, entry_path: str) -> str: + if os.path.isabs(entry_path) or ".." in entry_path.split("/"): + raise ValueError(f"Unsafe archive path: {entry_path}") + destination = os.path.abspath(os.path.normpath(os.path.join(target_dir, entry_path))) + target_root = os.path.abspath(os.path.normpath(target_dir)) + if destination != target_root and not destination.startswith(f"{target_root}/"): + raise ValueError(f"Unsafe archive path: {entry_path}") + return destination + + +def _apply_file_metadata(path: str, entry: libarchive.entry.ArchiveEntry) -> None: + if entry.mode and not os.path.islink(path): + try: + os.chmod(path, entry.mode) + except OSError as e: + logger.warning("Failed to set permissions for %s: %s", path, e) + if entry.mtime: + try: + os.utime(path, (entry.mtime, entry.mtime), follow_symlinks=False) + except OSError as e: + logger.warning("Failed to set mtime for %s: %s", path, e) + + +def _extract_entry(target_dir: str, entry: libarchive.entry.ArchiveEntry) -> None: + destination = _safe_entry_path(target_dir, entry.pathname) + parent_dir = os.path.dirname(destination) + if parent_dir: + os.makedirs(parent_dir, exist_ok=True) + + if entry.isdir: + if os.path.islink(destination): + os.remove(destination) + os.makedirs(destination, exist_ok=True) + elif entry.islnk: + if os.path.lexists(destination): + os.remove(destination) + os.symlink(entry.linkpath, destination) + elif entry.isfile: + if os.path.islink(destination): + os.remove(destination) + with open(destination, "wb") as file: + for block in entry.get_blocks(): + file.write(block) + else: + logger.warning("Skipped unsupported archive entry: %s", entry.pathname) + return + _apply_file_metadata(destination, entry) + + +def _read_xdg_desktop_dir() -> str: + config_path = os.path.join(os.path.expanduser("~"), ".config", "user-dirs.dirs") + try: + with open(config_path, encoding="utf-8") as file: + for line in file: + if line.startswith("XDG_DESKTOP_DIR="): + value = line.split("=", 1)[1].strip().strip('"') + return value.replace("$HOME", os.path.expanduser("~")) + except OSError: + pass + return "" + + +def _desktop_target_dir() -> str: + for path in ( + os.path.join(os.path.expanduser("~"), "Desktop"), + os.path.join(os.path.expanduser("~"), "Рабочий стол"), + _read_xdg_desktop_dir(), + ): + if path and os.path.isdir(path): + return path + return "" + + +def _copy_shortcut(desktop_path: str, target_dir: str) -> None: + if not target_dir: + return + os.makedirs(target_dir, exist_ok=True) + target_path = os.path.join(target_dir, os.path.basename(desktop_path)) + shutil.copyfile(desktop_path, target_path) + os.chmod(target_path, 0o755) + + +def _create_restore_shortcuts(port_data_path: str, prefix_name: str) -> None: + from portprotonqt.scripts_utils.shortcut_tools import create_shortcut + + manifest_path = os.path.join(_prefix_path(port_data_path, prefix_name), ".create_shortcut") + if not os.path.isfile(manifest_path): + return + + menu_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications") + desktop_dir = _desktop_target_dir() + with open(manifest_path, encoding="utf-8") as file: + for raw_line in file: + rel_path = raw_line.strip() + exe_path = os.path.join(_prefix_path(port_data_path, prefix_name), rel_path) + game_name = os.path.splitext(os.path.basename(exe_path))[0] + if not rel_path or not os.path.exists(exe_path): + continue + if not create_shortcut(exe_path, game_name): + continue + try: + desktop_path = os.path.join(port_data_path, f"{game_name}.desktop") + _copy_shortcut(desktop_path, menu_dir) + _copy_shortcut(desktop_path, desktop_dir) + except OSError as e: + logger.warning("Failed to create restored shortcut for %s: %s", exe_path, e) + + +def restore_backup( + port_data_path: str, + backup_file: str, + progress_callback: ProgressCallback | None = None, +) -> int: + prefix_name = os.path.basename(backup_file)[:-len(BACKUP_EXTENSION)] + target_dir = _prefix_path(port_data_path, prefix_name) + os.makedirs(target_dir, exist_ok=True) + + try: + archive_size = os.path.getsize(backup_file) or 1 + start_time = time.monotonic() + files_done = 0 + with libarchive.file_reader(backup_file) as archive: + for entry in archive: + _extract_entry(target_dir, entry) + files_done += 1 + bytes_read = archive.bytes_read + elapsed = max(time.monotonic() - start_time, 0.001) + speed = bytes_read / (1024 * 1024) / elapsed + percent = min(int((bytes_read / archive_size) * 100), 99) + progress = BackupProgress(percent, entry.pathname, files_done, 0, speed) + _emit_progress(progress_callback, progress) + _create_restore_shortcuts(port_data_path, prefix_name) + progress = BackupProgress(100, target_dir, files_done, files_done, 0.0) + _emit_progress(progress_callback, progress) + except (libarchive.ArchiveError, OSError, ValueError) as e: + logger.error("Failed to restore prefix backup %s: %s", backup_file, e) + return 1 + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="PortProton prefix backup helper") + parser.add_argument("--port-data-path", required=True) + subparsers = parser.add_subparsers(dest="command", required=True) + backup_parser = subparsers.add_parser("backup") + backup_parser.add_argument("prefix_name") + backup_parser.add_argument("backup_dir") + restore_parser = subparsers.add_parser("restore") + restore_parser.add_argument("backup_file") + args = parser.parse_args() + + if args.command == "backup": + return create_backup(args.port_data_path, args.prefix_name, args.backup_dir) + return restore_backup(args.port_data_path, args.backup_file) + + +if __name__ == "__main__": + sys.exit(main())