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())