diff --git a/build-aux/share/portproton/scripts/functions_helper b/build-aux/share/portproton/scripts/functions_helper index ce46489d..50623efe 100755 --- a/build-aux/share/portproton/scripts/functions_helper +++ b/build-aux/share/portproton/scripts/functions_helper @@ -145,7 +145,7 @@ pw_enable_idle_inhibit() { return fi - PW_INHIBIT_SLR="python3 -m portprotonqt.idle_inhibit" + PW_INHIBIT_SLR="python3 -m portprotonqt.scripts_utils.idle_inhibit" print_info "Screensaver will be inhibited using D-Bus" } @@ -1627,8 +1627,10 @@ stop_portproton () { find "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/drive_c/" -maxdepth 1 -type f -name "*.tmp" -delete pw_auto_create_shortcut add_in_stop_portproton - if [[ -n "$PW_POWERPROFILECTL_PROFILE" ]] && [[ "$(pw_ppd_dbus_get_profile)" != "$PW_POWERPROFILECTL_PROFILE" ]] ; then - pw_ppd_dbus_set_profile "$PW_POWERPROFILECTL_PROFILE" + if [[ -n "$PW_POWERPROFILECTL_PROFILE" ]] \ + && [[ "$(python3 -m portprotonqt.scripts_utils.power_profiles get)" != "$PW_POWERPROFILECTL_PROFILE" ]] + then + python3 -m portprotonqt.scripts_utils.power_profiles set "$PW_POWERPROFILECTL_PROFILE" &>/dev/null fi if [[ $PW_LOG != 1 ]] && [[ -n $START_PW_TIME_IN_GAME ]] ; then @@ -2251,15 +2253,12 @@ pw_create_gui_png () { if [[ -z $name_desktop_png ]] ; then basename_PW_EXE_FILE="$(basename "${PW_EXE_FILE}")" if echo "$basename_PW_EXE_FILE" | grep -ie 'setup\|install\|\.msi$' &>/dev/null ; then - export PW_ICON_FOR_YAD="${PORT_DATA_PATH}/data/img/setup.png" export name_desktop_png="setup" return 0 elif echo "$basename_PW_EXE_FILE" | grep -ie '\.reg$' &>/dev/null ; then - export PW_ICON_FOR_YAD="${PORT_DATA_PATH}/data/img/reg.png" export name_desktop_png="reg" return 0 elif echo "$basename_PW_EXE_FILE"| grep -ie '\.bat$' &>/dev/null ; then - export PW_ICON_FOR_YAD="${PORT_DATA_PATH}/data/img/bat.png" export name_desktop_png="bat" return 0 fi @@ -2304,7 +2303,7 @@ pw_create_gui_png () { fi fi - resize_png "$PW_EXE_FILE" "${PORTPROTON_NAME}" "48" "128" + resize_png "$PW_EXE_FILE" "${PORTPROTON_NAME}" PORTPROTON_NAME_PNG="${PORTPROTON_NAME// /_}" if [[ $PORTPROTON_NAME_PNG =~ [\!\%\$\&\<] ]] ; then @@ -2313,15 +2312,9 @@ pw_create_gui_png () { PORTPROTON_NAME_PNG="${PORTPROTON_NAME_PNG//$i/}" done fi - if [[ -z "${PW_ICON_FOR_YAD}" ]] ; then - if [[ -n "$(file "${PORT_DATA_PATH}/data/img/${PORTPROTON_NAME_PNG}.png" | grep "${PW_RESIZE_TO} x ${PW_RESIZE_TO}")" ]] ; then - export PW_ICON_FOR_YAD="${PORT_DATA_PATH}/data/img/${PORTPROTON_NAME_PNG}.png" - else - export PW_ICON_FOR_YAD="${PW_GUI_ICON_PATH}/port_proton.png" - fi - fi } + pw_create_unique_exe () { BASEDIR_GAME="$(dirname "$PW_EXE_FILE")" if [[ -d "$BASEDIR_GAME" ]] ; then @@ -2897,48 +2890,6 @@ start_portproton () { unset __GLX_VENDOR_LIBRARY_NAME fi -pw_ppd_dbus_get_profile() { - if ! command -v gdbus &>/dev/null; then - return 1 - fi - local result - result=$(gdbus call --system \ - --dest net.hadess.PowerProfiles \ - --object-path /net/hadess/PowerProfiles \ - --method org.freedesktop.DBus.Properties.Get \ - 'net.hadess.PowerProfiles' 'ActiveProfile' 2>/dev/null) - [[ -n "$result" ]] && echo "$result" | sed "s/.*'\([^']*\)'.*/\1/" || return 1 -} -export -f pw_ppd_dbus_get_profile - -pw_ppd_dbus_set_profile() { - local profile="$1" - if ! command -v gdbus &>/dev/null; then - return 1 - fi - gdbus call --system \ - --dest net.hadess.PowerProfiles \ - --object-path /net/hadess/PowerProfiles \ - --method org.freedesktop.DBus.Properties.Set \ - 'net.hadess.PowerProfiles' 'ActiveProfile' "<'$profile'>" &>/dev/null -} -export -f pw_ppd_dbus_set_profile - -pw_ppd_dbus_has_profile() { - local profile="$1" - if ! command -v gdbus &>/dev/null; then - return 1 - fi - local result - result=$(gdbus call --system \ - --dest net.hadess.PowerProfiles \ - --object-path /net/hadess/PowerProfiles \ - --method org.freedesktop.DBus.Properties.GetAll \ - 'net.hadess.PowerProfiles' 2>/dev/null) - [[ "$result" == *"'$profile'"* ]] -} -export -f pw_ppd_dbus_has_profile - if check_gamescope_session ; then export PW_GAMEMODERUN_SLR="" elif [[ "$PW_USE_GAMEMODE" = "1" ]] \ @@ -2947,9 +2898,12 @@ export -f pw_ppd_dbus_has_profile if pw_is_process_active ananicy ananicy-cpp scx scx_loader falcond ; then export GAMEMODERUN=0 export PW_GAMEMODERUN_SLR="" - if pw_ppd_dbus_has_profile "performance" && [[ "$(pw_ppd_dbus_get_profile)" != "performance" ]] ; then - export PW_POWERPROFILECTL_PROFILE=$(pw_ppd_dbus_get_profile) - pw_ppd_dbus_set_profile performance + local current_power_profile + current_power_profile=$(python3 -m portprotonqt.scripts_utils.power_profiles get) + if [[ -n "$current_power_profile" && "$current_power_profile" != "performance" ]] \ + && python3 -m portprotonqt.scripts_utils.power_profiles set performance &>/dev/null + then + export PW_POWERPROFILECTL_PROFILE="$current_power_profile" print_info "Gamemode replaced by power-profiles-daemon/tuned-ppd/tlp-pd via D-Bus to avoid conflicts" else export PW_POWERPROFILECTL_PROFILE="" @@ -4443,11 +4397,11 @@ pw_gamescope_process () { # RESIZE PNG FILE resize_png () { - if [[ -z "$1" ]] || [[ -z "$2" ]] || [[ -z "$3" ]] ; then + if [[ -z "$1" ]] || [[ -z "$2" ]] ; then print_error "no argument specified for resize_png" return 1 else - local RESIZE_FILE RESIZE_NAME_PNG resize_to resize_to_helper + local RESIZE_FILE RESIZE_NAME_PNG RESIZE_FILE="$1" RESIZE_NAME_PNG="${2// /_}" fi @@ -4456,44 +4410,26 @@ resize_png () { then RESIZE_NAME_PNG=${RESIZE_NAME_PNG/*ru.linux_gaming.PortProton\/data\/img\//} fi - for resize_to in "${@:3}" ; do - if [[ $resize_to == 128 ]] - then resize_to_helper="" - else resize_to_helper="_$resize_to" - fi + if [[ $RESIZE_NAME_PNG =~ [\!\%\$\&\<] ]] ; then + local ICON_NAME_REGEX=(\! % \$ \& \<) + for i in "${ICON_NAME_REGEX[@]}" ; do + RESIZE_NAME_PNG="${RESIZE_NAME_PNG//$i/}" + done + fi - if [[ $RESIZE_NAME_PNG =~ [\!\%\$\&\<] ]] ; then - local ICON_NAME_REGEX=(\! % \$ \& \<) - for i in "${ICON_NAME_REGEX[@]}" ; do - RESIZE_NAME_PNG="${RESIZE_NAME_PNG//$i/}" - done - fi + if [[ -f "${PORT_DATA_PATH}/data/img/${RESIZE_NAME_PNG}.png" ]] \ + || [[ ! -f "${RESIZE_FILE}" ]] \ + || [[ ! ${RESIZE_FILE,,} =~ .exe$ ]] + then + return 0 + fi - if [[ -f "${PORT_DATA_PATH}/data/img/${RESIZE_NAME_PNG}${resize_to_helper}.png" ]] \ - || [[ ! -f "${RESIZE_FILE}" ]] \ - || [[ ! ${RESIZE_FILE,,} =~ .exe$ ]] - then - return 0 - fi + try_remove_file "${PORT_DATA_PATH}/data/img/launcher.png" + try_remove_file "${PORT_DATA_PATH}/data/img/Launcher.png" - try_remove_file "${PORT_DATA_PATH}/data/img/launcher.png" - try_remove_file "${PORT_DATA_PATH}/data/img/Launcher.png" - - if check_flatpak ; then - if ! timeout 3 \ - exe-thumbnailer --force-resize -s "$resize_to" "$(readlink -f "${RESIZE_FILE}")" "${PORT_DATA_PATH}/data/img/${RESIZE_NAME_PNG}${resize_to_helper}.png" \ - && [[ "$ALPINE_FP" != "1" ]] - then - print_error "exe-thumbnailer - broken!" - fi - else - print_warning "use portable exe-thumbnailer" - env PYTHONPATH="${PW_PLUGINS_PATH}/portable/lib/python3.9/site-packages/" \ - LD_LIBRARY_PATH="${PW_PLUGINS_PATH}/portable/lib/lib64" \ - "${PW_WINELIB}/runtime/files/bin/python3.9" \ - "${PW_PLUGINS_PATH}/portable/bin/exe-thumbnailer" --force-resize -s "$resize_to" "$(readlink -f "${RESIZE_FILE}")" "${PORT_DATA_PATH}/data/img/${RESIZE_NAME_PNG}${resize_to_helper}.png" - fi - done + python3 -m portprotonqt.scripts_utils.shortcut_tools thumbnail \ + "$(readlink -f "${RESIZE_FILE}")" \ + "${PORT_DATA_PATH}/data/img/${RESIZE_NAME_PNG}.png" } portwine_create_shortcut () { @@ -4527,19 +4463,12 @@ portwine_output_yad_shortcut () { export name_desktop="$PW_NAME_DESKTOP_PROXY" fi - echo "[Desktop Entry]" > "${PORT_DATA_PATH}/${name_desktop}.desktop" - echo "Name=${name_desktop}" >> "${PORT_DATA_PATH}/${name_desktop}.desktop" - if check_flatpak - then echo "Exec=flatpak run ${FLATPAK_ID} --silent \"${PW_EXE_FILE}\"" >> "${PORT_DATA_PATH}/${name_desktop}.desktop" - else echo "Exec=portprotonqt --silent \"${PW_EXE_FILE}\"" >> "${PORT_DATA_PATH}/${name_desktop}.desktop" + if ! python3 -m portprotonqt.scripts_utils.shortcut_tools shortcut \ + "${PW_EXE_FILE}" \ + "${name_desktop}" + then + return 1 fi - { - echo "Type=Application" - echo "Categories=Game" - echo "StartupNotify=true" - echo "Icon=${PORT_DATA_PATH}/data/img/${name_desktop_png}.png" - } >> "${PORT_DATA_PATH}/${name_desktop}.desktop" - chmod +x "${PORT_DATA_PATH}/${name_desktop}.desktop" if [[ "${PW_SHORTCUT_MENU}" == "TRUE" ]] ; then try_remove_file "${HOME}/.local/share/applications/${name_desktop}.desktop" @@ -4609,7 +4538,7 @@ pw_auto_create_shortcut () { pw_init_db export PW_NO_RESTART_PPDB="1" edit_db_from_gui PW_PREFIX_NAME LAUNCH_URI - unset PW_ICON_FOR_YAD name_desktop_png + unset name_desktop_png portwine_create_shortcut fi fi @@ -4643,9 +4572,9 @@ pw_auto_create_shortcut () { fi print_info "Created link for: $link_name" PORTPROTON_NAME="$link_name" - resize_png "$PW_EXE_FILE" "${PORTPROTON_NAME}" "48" "128" + resize_png "$PW_EXE_FILE" "${PORTPROTON_NAME}" export PW_NO_RESTART_PPDB=1 - unset PW_ICON_FOR_YAD name_desktop_png + unset name_desktop_png portwine_create_shortcut done } diff --git a/build-aux/share/portproton/scripts/portwine_db/win_install_student.ppdb b/build-aux/share/portproton/scripts/portwine_db/win_install_student.ppdb index b7dd8dc2..2731fc57 100644 --- a/build-aux/share/portproton/scripts/portwine_db/win_install_student.ppdb +++ b/build-aux/share/portproton/scripts/portwine_db/win_install_student.ppdb @@ -6,7 +6,6 @@ export PW_VULKAN_USE="0" export PW_WINE_USE="PROTON_LG" export PW_PREFIX_NAME="STUDENT" export PW_DISABLED_CREATE_DB="1" -export PW_ICON_FOR_YAD="${PORT_DATA_PATH}/data/img/setup.png" export PW_USE_FSYNC="0" export PW_USE_ESYNC="0" export PW_DLL_INSTALL="" diff --git a/build-aux/share/portproton/scripts/portwine_db/win_install_teacher.ppdb b/build-aux/share/portproton/scripts/portwine_db/win_install_teacher.ppdb index 808f7d91..39440842 100644 --- a/build-aux/share/portproton/scripts/portwine_db/win_install_teacher.ppdb +++ b/build-aux/share/portproton/scripts/portwine_db/win_install_teacher.ppdb @@ -6,7 +6,6 @@ export PW_VULKAN_USE="0" export PW_WINE_USE="PROTON_LG" export PW_PREFIX_NAME="TEACHER" export PW_DISABLED_CREATE_DB="1" -export PW_ICON_FOR_YAD="${PORT_DATA_PATH}/data/img/setup.png" export PW_USE_FSYNC="0" export PW_USE_ESYNC="0" export PW_DLL_INSTALL="" diff --git a/portprotonqt/config/portproton.py b/portprotonqt/config/portproton.py index d95fb45c..115d5533 100644 --- a/portprotonqt/config/portproton.py +++ b/portprotonqt/config/portproton.py @@ -19,6 +19,14 @@ logger = get_logger(__name__) _portproton_start_command: list[str] | None = None +def _sanitize_icon_name(name: str) -> str: + """Convert a game name to a safe icon file name.""" + sanitized_name = name.replace(" ", "_") + for char in ("!", "%", "$", "&", "<"): + sanitized_name = sanitized_name.replace(char, "") + return sanitized_name + + class PortProtonConfig(BaseConfig): """PortProton location configuration.""" @@ -356,8 +364,8 @@ def find_game_by_exe(exe_path: str) -> configparser.SectionProxy | None: def create_desktop_file( exe_path: str, game_name: str | None = None, -) -> tuple[str, str] | None: - """Create desktop entry content and destination path for a game.""" +) -> tuple[str, str, str] | None: + """Create desktop entry content, destination path and icon path for a game.""" portproton_path = get_portproton_location() if not os.path.isfile(exe_path): logger.error("Executable not found: %s", exe_path) @@ -369,7 +377,8 @@ def create_desktop_file( if not game_name: game_name = os.path.splitext(os.path.basename(exe_path))[0] base_path = os.path.join(portproton_path, "data") - icon_path = os.path.join(base_path, "img", f"{game_name}.png") + icon_name = _sanitize_icon_name(game_name) + icon_path = os.path.join(base_path, "img", f"{icon_name}.png") desktop_path = os.path.join(portproton_path, f"{game_name}.desktop") os.makedirs(os.path.dirname(icon_path), exist_ok=True) @@ -391,4 +400,4 @@ def create_desktop_file( "StartupNotify=true\n" f"Icon={icon_path}\n" ) - return desktop_entry, desktop_path + return desktop_entry, desktop_path, icon_path diff --git a/portprotonqt/context_menu_manager.py b/portprotonqt/context_menu_manager.py index 9bb29721..9425e0fd 100644 --- a/portprotonqt/context_menu_manager.py +++ b/portprotonqt/context_menu_manager.py @@ -21,7 +21,8 @@ from portprotonqt.steam_api import ( remove_from_steam, safe_vdf_load, ) -from portprotonqt.dialogs import AddGameDialog, generate_thumbnail +from portprotonqt.icon_extractor import generate_thumbnail +from portprotonqt.dialogs import AddGameDialog from portprotonqt.dialogs.base import DraggableDialog from portprotonqt.theme_manager import ThemeManager from portprotonqt.logger import get_logger diff --git a/portprotonqt/dialogs.py b/portprotonqt/dialogs.py index 4f0bbeb7..2bdb82b6 100644 --- a/portprotonqt/dialogs.py +++ b/portprotonqt/dialogs.py @@ -5,10 +5,10 @@ New code should import directly from portprotonqt.dialogs package. """ # Re-export all dialog classes for backward compatibility +from portprotonqt.icon_extractor import generate_thumbnail from portprotonqt.dialogs.base import ( GameLaunchDialog, AddGameDialog, - generate_thumbnail, FileSelectedSignal, ) from portprotonqt.dialogs.file_explorer import FileExplorer diff --git a/portprotonqt/dialogs/__init__.py b/portprotonqt/dialogs/__init__.py index 62367ed5..e068fb13 100644 --- a/portprotonqt/dialogs/__init__.py +++ b/portprotonqt/dialogs/__init__.py @@ -5,7 +5,8 @@ from portprotonqt.dialogs.file_explorer import FileExplorer from portprotonqt.dialogs.winetricks_dialog import WinetricksDialog from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog from portprotonqt.dialogs.proton_manager import ProtonManager -from portprotonqt.dialogs.base import GameLaunchDialog, AddGameDialog, generate_thumbnail, FileSelectedSignal +from portprotonqt.icon_extractor import generate_thumbnail +from portprotonqt.dialogs.base import GameLaunchDialog, AddGameDialog, FileSelectedSignal __all__ = [ 'create_dialog_hints_widget', diff --git a/portprotonqt/dialogs/base.py b/portprotonqt/dialogs/base.py index 9a75e800..892eecce 100644 --- a/portprotonqt/dialogs/base.py +++ b/portprotonqt/dialogs/base.py @@ -9,7 +9,6 @@ from PySide6.QtWidgets import ( QProgressBar, QSizePolicy, QWidget, QCheckBox ) from PySide6.QtCore import Qt, QObject, Signal, QTimer, QStandardPaths, QSize -from PIL import Image import psutil if TYPE_CHECKING: @@ -23,80 +22,13 @@ from portprotonqt.downloader import Downloader from portprotonqt.virtual_keyboard import VirtualKeyboard from portprotonqt.localization import _ from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS, set_animated_cover -from portprotonqt.icon_extractor import IconExtractor, IconExtractorError +from portprotonqt.icon_extractor import generate_thumbnail logger = get_logger(__name__) theme_manager = ThemeManager() DISC_IMAGE_EXTENSIONS = (".iso", ".mdf") -def generate_thumbnail(inputfile, outfile, size=128, force_resize=True): - """ - Generates a thumbnail for an .exe file. - - inputfile: the input file path (%i) - outfile: output filename (%o) - size: determines the thumbnail output size (%s) - """ - logger.debug(f"Starting thumbnail generation: {inputfile} → {outfile}, size={size}, force={force_resize}") - - try: - extractor = IconExtractor(inputfile) - logger.debug("IconExtractor created successfully.") - except (RuntimeError, IconExtractorError) as e: - logger.warning(f"Failed to create IconExtractor: {e}") - return False - - try: - data = extractor.get_icon() - if data is None: - return False - im = Image.open(data) - logger.debug(f"Extracted icon size {im.size}, formats: {im.format}, frames: {getattr(im, 'n_frames', 1)}") - except Exception as e: - logger.warning(f"Error extracting icon: {e}") - return False - - if force_resize: - logger.debug(f"Forcing icon resize to {size}x{size}") - im = im.resize((size, size), Image.Resampling.LANCZOS) - else: - if size > 256: - logger.warning('Requested size larger than 256, set to 256') - size = 256 - elif size not in (128, 256): - logger.warning(f'Unsupported size {size}, set to 128') - size = 128 - - if size == 256: - logger.debug("Saving icon without resize (256x256)") - im.save(outfile, "PNG") - logger.info(f"Icon saved to {outfile}") - return True - - frames = getattr(im, 'n_frames', 1) - try: - for frame in range(frames): - im.seek(frame) - if im.size == (size, size): - logger.debug(f"Found frame with size {size}x{size}") - break - except EOFError: - logger.debug("Frames ended before finding required size.") - - if im.size != (size, size): - logger.debug(f"Resizing from {im.size} to {size}x{size}") - im = im.resize((size, size)) - - try: - im.save(outfile, "PNG") - logger.info(f"Thumbnail successfully saved to {outfile}") - return True - except Exception as e: - logger.error(f"Error saving thumbnail: {e}") - return False - - class FileSelectedSignal(QObject): """Signal class for file selection.""" file_selected = Signal(str) @@ -538,9 +470,7 @@ class AddGameDialog(DraggableDialog): result = create_desktop_file(exe_path, name) if not result: return None, None - desktop_entry, desktop_path = result - base_path = os.path.join(os.path.dirname(desktop_path), "data") - icon_path = os.path.join(base_path, "img", f"{name}.png") + desktop_entry, desktop_path, icon_path = result if not self.last_cover_path and not generate_thumbnail(launch_path, icon_path, size=128): logger.error(f"Failed to generate thumbnail from exe: {launch_path}") diff --git a/portprotonqt/dialogs/file_explorer.py b/portprotonqt/dialogs/file_explorer.py index 2868c902..105b07e1 100644 --- a/portprotonqt/dialogs/file_explorer.py +++ b/portprotonqt/dialogs/file_explorer.py @@ -21,7 +21,8 @@ from portprotonqt.theme_manager import ThemeManager from portprotonqt.custom_widgets import AutoSizeButton from portprotonqt.localization import _ from portprotonqt.dialogs.dialog_utils import create_dialog_hints_widget, update_dialog_hints -from portprotonqt.dialogs.base import DraggableDialog, generate_thumbnail, FileSelectedSignal +from portprotonqt.icon_extractor import generate_thumbnail +from portprotonqt.dialogs.base import DraggableDialog, FileSelectedSignal from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS logger = get_logger(__name__) diff --git a/portprotonqt/icon_extractor.py b/portprotonqt/icon_extractor.py index 311327c2..08e27c04 100644 --- a/portprotonqt/icon_extractor.py +++ b/portprotonqt/icon_extractor.py @@ -12,6 +12,36 @@ RT_ICON = 3 RT_GROUP_ICON = 14 THUMBNAIL_SIZE = 128 + +def generate_thumbnail(inputfile: str, outfile: str, size: int = 128, force_resize: bool = True) -> bool: + """ + Generates a thumbnail for an .exe file. + + inputfile: the input file path + outfile: output filename + size: determines the thumbnail output size + """ + logger.debug("Starting thumbnail generation: %s → %s, size=%s, force=%s", inputfile, outfile, size, force_resize) + + try: + extractor = IconExtractor(inputfile) + data = extractor.get_icon() + if data is None: + return False + + im = Image.open(data) + if force_resize or im.size != (size, size): + logger.debug("Resizing icon to %sx%s", size, size) + im = im.resize((size, size), Image.Resampling.LANCZOS) + + im.save(outfile, "PNG") + logger.info("Thumbnail successfully saved to %s", outfile) + return True + except Exception as e: + logger.error("Error generating thumbnail: %s", e) + return False + + class IconExtractorError(Exception): """Base exception for icon extraction errors.""" diff --git a/portprotonqt/main_window.py b/portprotonqt/main_window.py index 88f5aecc..383d3c7f 100644 --- a/portprotonqt/main_window.py +++ b/portprotonqt/main_window.py @@ -6,7 +6,8 @@ import subprocess import psutil import re from portprotonqt.logger import get_logger -from portprotonqt.dialogs import AddGameDialog, FileExplorer, WinetricksDialog, ExeSettingsDialog, generate_thumbnail +from portprotonqt.icon_extractor import generate_thumbnail +from portprotonqt.dialogs import AddGameDialog, FileExplorer, WinetricksDialog, ExeSettingsDialog from portprotonqt.game_card import GameCard from portprotonqt.animations import DetailPageAnimations from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton, NavLabel, FlowLayout diff --git a/portprotonqt/meson.build b/portprotonqt/meson.build index ea45b0bc..23b67d19 100644 --- a/portprotonqt/meson.build +++ b/portprotonqt/meson.build @@ -12,7 +12,6 @@ install_data( 'game_card.py', 'game_library_manager.py', 'howlongtobeat_api.py', - 'idle_inhibit.py', 'image_utils.py', 'icon_extractor.py', 'input_manager.py', @@ -119,6 +118,16 @@ install_data( install_dir: pythondir / meson.project_name() / 'debug_utils', ) +# Install scripts_utils package +install_data( + ['scripts_utils/__init__.py', + 'scripts_utils/idle_inhibit.py', + 'scripts_utils/power_profiles.py', + 'scripts_utils/shortcut_tools.py', + ], + install_dir: pythondir / meson.project_name() / 'scripts_utils', +) + # Install config package install_data( ['config/__init__.py', diff --git a/portprotonqt/scripts_utils/__init__.py b/portprotonqt/scripts_utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/portprotonqt/idle_inhibit.py b/portprotonqt/scripts_utils/idle_inhibit.py similarity index 98% rename from portprotonqt/idle_inhibit.py rename to portprotonqt/scripts_utils/idle_inhibit.py index 85f3f97c..f77c8624 100644 --- a/portprotonqt/idle_inhibit.py +++ b/portprotonqt/scripts_utils/idle_inhibit.py @@ -5,6 +5,9 @@ import sys from collections.abc import Awaitable from typing import Any +import dbus_fast +import dbus_fast.aio + from portprotonqt.logger import get_logger from portprotonqt.localization import _ @@ -38,9 +41,6 @@ async def _dbus_call(awaitable: Awaitable[Any]) -> Any: async def _request_inhibit() -> tuple[Any, int, Any]: - import dbus_fast - import dbus_fast.aio - bus = await _dbus_call(dbus_fast.aio.MessageBus().connect()) introspection = await _dbus_call(bus.introspect(BUS_NAME, BUS_PATH)) proxy = bus.get_proxy_object(BUS_NAME, BUS_PATH, introspection) diff --git a/portprotonqt/scripts_utils/power_profiles.py b/portprotonqt/scripts_utils/power_profiles.py new file mode 100644 index 00000000..f1677dac --- /dev/null +++ b/portprotonqt/scripts_utils/power_profiles.py @@ -0,0 +1,162 @@ +"""Power profile tools for PortProton scripts.""" +import argparse +import asyncio +import sys +from typing import Any + +from dbus_fast import BusType, Message, Variant +from dbus_fast.aio import MessageBus + +from portprotonqt.logger import get_logger + + +logger = get_logger(__name__) + + +Endpoint = tuple[str, str] +POWER_PROFILE_ENDPOINTS: tuple[Endpoint, ...] = ( + ( + "org.freedesktop.UPower.PowerProfiles", + "/org/freedesktop/UPower/PowerProfiles", + ), + ( + "net.hadess.PowerProfiles", + "/net/hadess/PowerProfiles", + ), +) +PROPERTIES_INTERFACE = "org.freedesktop.DBus.Properties" +ACTIVE_PROFILE = "ActiveProfile" +PROFILES = "Profiles" +DBUS_TIMEOUT = 10 + + +async def _dbus_call(bus: Any, message: Any) -> Any: + reply = await asyncio.wait_for(bus.call(message), timeout=DBUS_TIMEOUT) + if reply.message_type.name == "ERROR": + return None + return reply.body + + +async def _get_property(bus: Any, endpoint: Endpoint, name: str) -> Any: + bus_name, bus_path = endpoint + body = await _dbus_call( + bus, + Message( + destination=bus_name, + path=bus_path, + interface=PROPERTIES_INTERFACE, + member="Get", + signature="ss", + body=[bus_name, name], + ), + ) + if not body: + return None + return _variant_value(body[0]) + + +async def _active_profile(bus: Any) -> tuple[Endpoint, str] | None: + for endpoint in POWER_PROFILE_ENDPOINTS: + try: + profile = await _get_property(bus, endpoint, ACTIVE_PROFILE) + except Exception as error: + logger.debug("Power profile endpoint unavailable: %s", error) + continue + if isinstance(profile, str): + return endpoint, profile + return None + + +async def get_profile() -> str | None: + """Return active power profile.""" + bus = await MessageBus(bus_type=BusType.SYSTEM).connect() + active_profile = await _active_profile(bus) + if not active_profile: + return None + return active_profile[1] + + +async def set_profile(profile: str) -> bool: + """Set active power profile.""" + bus = await MessageBus(bus_type=BusType.SYSTEM).connect() + active_profile = await _active_profile(bus) + if not active_profile: + return False + + bus_name, bus_path = active_profile[0] + profiles = await _get_property(bus, active_profile[0], PROFILES) + if profile not in _profile_names(profiles): + return False + + body = await _dbus_call( + bus, + Message( + destination=bus_name, + path=bus_path, + interface=PROPERTIES_INTERFACE, + member="Set", + signature="ssv", + body=[bus_name, ACTIVE_PROFILE, Variant("s", profile)], + ), + ) + return body is not None + + +def _variant_value(value: Any) -> Any: + if isinstance(value, Variant): + return _variant_value(value.value) + if isinstance(value, dict): + return { + _variant_value(key): _variant_value(item) + for key, item in value.items() + } + if isinstance(value, list): + return [_variant_value(item) for item in value] + return value + + +def _profile_names(profiles: Any) -> set[str]: + if not isinstance(profiles, list): + return set() + + names = set() + for profile in profiles: + if isinstance(profile, dict) and isinstance(profile.get("Profile"), str): + names.add(profile["Profile"]) + return names + + +def parse_args() -> argparse.Namespace: + """Parse arguments for power profile tools.""" + parser = argparse.ArgumentParser(description="PortProtonQt power profile tools") + subparsers = parser.add_subparsers(dest="command", required=True) + subparsers.add_parser("get", help="Print active power profile") + + set_parser = subparsers.add_parser("set", help="Set active power profile") + set_parser.add_argument("profile", help="Power profile name") + return parser.parse_args() + + +async def _main() -> int: + args = parse_args() + try: + if args.command == "get": + profile = await get_profile() + if not profile: + return 1 + print(profile) + return 0 + if args.command == "set": + return 0 if await set_profile(args.profile) else 1 + except Exception as error: + logger.debug("Power profile command failed: %s", error) + return 1 + return 1 + + +def main() -> int: + return asyncio.run(_main()) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/portprotonqt/scripts_utils/shortcut_tools.py b/portprotonqt/scripts_utils/shortcut_tools.py new file mode 100644 index 00000000..777e732d --- /dev/null +++ b/portprotonqt/scripts_utils/shortcut_tools.py @@ -0,0 +1,89 @@ +"""Entry point for shortcut and thumbnail generation tools.""" +import argparse +import os +import sys +from pathlib import Path +from portprotonqt.cli import normalize_launch_path + + +def create_shortcut(exe_path: str, game_name: str | None = None) -> bool: + """Create a PortProtonQt desktop shortcut for an executable.""" + from portprotonqt.config import create_desktop_file + from portprotonqt.icon_extractor import generate_thumbnail + from portprotonqt.logger import get_logger + + logger = get_logger(__name__) + normalized_path = normalize_launch_path(exe_path) + result = create_desktop_file(normalized_path, game_name) + if not result: + return False + + desktop_entry, desktop_path, icon_path = result + + if not generate_thumbnail(normalized_path, icon_path, size=128): + logger.error("Failed to generate thumbnail from exe: %s", normalized_path) + desktop_entry = desktop_entry.replace(f"Icon={icon_path}\n", "Icon=\n") + + try: + Path(desktop_path).write_text(desktop_entry, encoding="utf-8") + os.chmod(desktop_path, 0o755) + except OSError as error: + logger.error("Failed to save desktop file %s: %s", desktop_path, error) + return False + + return True + + +def create_thumbnail(exe_path: str, output_path: str) -> bool: + """Create a PNG thumbnail for an executable.""" + from portprotonqt.icon_extractor import generate_thumbnail + from portprotonqt.logger import get_logger + + logger = get_logger(__name__) + normalized_path = normalize_launch_path(exe_path) + if not normalized_path or not output_path: + return False + + try: + output_dir = os.path.dirname(output_path) + if output_dir: + os.makedirs(output_dir, exist_ok=True) + except OSError as error: + logger.error("Failed to prepare thumbnail directory %s: %s", output_path, error) + return False + + if not generate_thumbnail(normalized_path, output_path, size=128): + logger.error("Failed to generate thumbnail: %s", normalized_path) + return False + + return True + + +def parse_args(): + """Parse arguments for shortcut tools.""" + parser = argparse.ArgumentParser(description="PortProtonQt shortcut tools") + subparsers = parser.add_subparsers(dest="command", required=True) + + shortcut_parser = subparsers.add_parser("shortcut", help="Create a desktop shortcut") + shortcut_parser.add_argument("exe_path", help="Path to the executable") + shortcut_parser.add_argument("game_name", help="Name of the game") + + thumbnail_parser = subparsers.add_parser("thumbnail", help="Create a PNG thumbnail") + thumbnail_parser.add_argument("exe_path", help="Path to the executable") + thumbnail_parser.add_argument("output_path", help="Path to the output PNG file") + + return parser.parse_args() + + +def main() -> int: + """Main entry point for shortcut tools.""" + args = parse_args() + if args.command == "shortcut": + return 0 if create_shortcut(args.exe_path, args.game_name) else 1 + if args.command == "thumbnail": + return 0 if create_thumbnail(args.exe_path, args.output_path) else 1 + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/portprotonqt/steam_api/shortcuts.py b/portprotonqt/steam_api/shortcuts.py index 7f35a043..a41b40e6 100644 --- a/portprotonqt/steam_api/shortcuts.py +++ b/portprotonqt/steam_api/shortcuts.py @@ -15,7 +15,7 @@ import websocket from portprotonqt.logger import get_logger from portprotonqt.downloader import Downloader -from portprotonqt.dialogs import generate_thumbnail +from portprotonqt.icon_extractor import generate_thumbnail from portprotonqt.config import ( extract_exec_target_path, get_portproton_location,