diff --git a/build-aux/AppImage/get-dependencies.sh b/build-aux/AppImage/get-dependencies.sh index 2522e9c8..d1956b6f 100755 --- a/build-aux/AppImage/get-dependencies.sh +++ b/build-aux/AppImage/get-dependencies.sh @@ -83,23 +83,6 @@ echo "---------------------------------------------------------------" pacman-key --init pacman -S --noconfirm archlinux-keyring -# Chaotic-AUR keys -pacman-key --recv-key 3056513887B78AEB --keyserver keyserver.ubuntu.com -pacman-key --lsign-key 3056513887B78AEB - -pacman -U --noconfirm 'https://cdn-mirror.chaotic.cx/chaotic-aur/chaotic-keyring.pkg.tar.zst' -pacman -U --noconfirm 'https://cdn-mirror.chaotic.cx/chaotic-aur/chaotic-mirrorlist.pkg.tar.zst' - -# Enable the multilib repository -cat << EOM >> /etc/pacman.conf -[multilib] -Include = /etc/pacman.d/mirrorlist - -[chaotic-aur] -Include = /etc/pacman.d/chaotic-mirrorlist -SigLevel = Never -EOM - # Disable locale noextract sed -i -E 's@[[:space:]]*usr/share/locale/\*@@g; s@[[:space:]]+@ @g; s@[[:space:]]+$@@' /etc/pacman.conf diff --git a/build-aux/AppImage/portprotonqt-appimage.sh b/build-aux/AppImage/portprotonqt-appimage.sh index b1500973..91ff6976 100755 --- a/build-aux/AppImage/portprotonqt-appimage.sh +++ b/build-aux/AppImage/portprotonqt-appimage.sh @@ -14,6 +14,7 @@ export UPINFO="gitea-releases-zsync|git.linux-gaming.ru|Linux-Gaming|PortProtonQ export DEPLOY_OPENGL=1 export DEPLOY_SDL=1 export DEPLOY_PYTHON=1 +export MAIN_BIN=portprotonqt export OPTIMIZE_LAUNCH=1 # Adjust comp settings to bypass oom-killer @@ -37,6 +38,10 @@ elif [ -d /usr/share/portproton ]; then fi +# QSoundEffect only needs PCM WAV and PulseAudio. Remove the optional native +# PipeWire path and FFmpeg media backend before quick-sharun scans Qt plugins. +pacman -Rdd --noconfirm libpipewire qt6-multimedia-ffmpeg + # Deploy dependencies # Qt libs have to be passed manually due to the app being a python script ./quick-sharun \ @@ -46,8 +51,51 @@ fi /usr/lib/libQt6Gui.so* \ /usr/lib/libQt6Multimedia.so* \ /usr/lib/libQt6Network.so* \ - /usr/lib/qt6/plugins/multimedia/libffmpegmediaplugin.so \ /usr/lib/qt6/plugins/imageformats/libqwebp.so +# The native PipeWire path rejects some IEC958 device formats. Qt's PulseAudio +# path works with both PulseAudio and pipewire-pulse on the host. +echo 'QT_AUDIO_BACKEND=pulseaudio' >> ./AppDir/.env + +# DEPLOY_PYTHON copies the distro's complete Python installation. Remove +# build/development content and PySide bindings not imported by PortProtonQt. +PYTHON_DIR=$(find ./AppDir/lib -maxdepth 1 -type d -name 'python3.*' -print -quit) +PYTHON_SITE=$PYTHON_DIR/site-packages +PYSIDE_DIR=$PYTHON_SITE/PySide6 + +rm -rf \ + "$PYTHON_DIR"/ensurepip \ + "$PYTHON_DIR"/idlelib \ + "$PYTHON_DIR"/pydoc_data \ + "$PYTHON_DIR"/tkinter \ + "$PYTHON_DIR"/turtledemo \ + "$PYTHON_DIR"/venv \ + "$PYTHON_SITE"/Cython \ + "$PYTHON_SITE"/cython.py \ + "$PYTHON_SITE"/mesonbuild \ + "$PYTHON_SITE"/pygments \ + "$PYTHON_SITE"/pyximport \ + "$PYTHON_SITE"/setuptools \ + "$PYTHON_SITE"/vapoursynth \ + "$PYTHON_SITE"/wheel \ + "$PYTHON_SITE"/cython-*.dist-info \ + "$PYTHON_SITE"/meson-*.dist-info \ + "$PYTHON_SITE"/pygments-*.dist-info \ + "$PYTHON_SITE"/setuptools-*.dist-info \ + "$PYTHON_SITE"/vapoursynth-*.dist-info \ + "$PYTHON_SITE"/wheel-*.dist-info + +find "$PYTHON_DIR" -depth -type d \( -name test -o -name tests \) \ + -exec rm -rf {} + +find "$PYTHON_SITE" -type f -name '*.pyi' -delete + +for module in "$PYSIDE_DIR"/Qt*.so; do + case "${module##*/}" in + QtCore.*|QtGui.*|QtMultimedia.*|QtNetwork.*|QtSvg.*|QtWidgets.*) continue ;; + esac + rm -f "$module" +done +rm -rf "$PYSIDE_DIR"/QtAsyncio + # Turn AppDir into AppImage ./quick-sharun --make-appimage diff --git a/documentation/theme_guide/README.md b/documentation/theme_guide/README.md index b8c09064..0426f48b 100644 --- a/documentation/theme_guide/README.md +++ b/documentation/theme_guide/README.md @@ -762,9 +762,9 @@ Place UI sound effects in the theme's `sounds/` directory. File names must match | `game_launch` | Successful game process launch | | `gamepad_connect` | Connecting a gamepad | -For example, use `sounds/navigate.ogg` or `sounds/navigate.wav`. Supported extensions, in lookup order, are `.ogg`, `.oga`, `.wav`, `.mp3`, `.flac`, `.opus`, `.m4a`, `.aac`, and `.webm`. Availability of a codec at playback time depends on the Qt Multimedia backend installed on the system. +Use uncompressed PCM WAV files, for example `sounds/navigate.wav`. Other audio formats are not supported. -Sounds are inherited per event. A child theme can override only `navigate`; missing events are resolved through its parent chain up to `standart`. When more than one supported extension exists for the same event in one theme, the first extension in the list above is used. +Sounds are inherited per event. A child theme can override only `navigate`; missing events are resolved through its parent chain up to `standart`. Theme validation checks the extension, file size, and format signature before a sound is loaded. Sound files larger than 20 MiB, symlinks, files outside the theme directory, and files whose content does not match their extension are rejected. Include the license and attribution required by every sound asset distributed with a theme. diff --git a/documentation/theme_guide/README.ru.md b/documentation/theme_guide/README.ru.md index 996f91ee..1f03d5c0 100644 --- a/documentation/theme_guide/README.ru.md +++ b/documentation/theme_guide/README.ru.md @@ -762,9 +762,9 @@ ICON_COLORS = { | `game_launch` | Успешный запуск процесса игры | | `gamepad_connect` | Подключение геймпада | -Например, используйте `sounds/navigate.ogg` или `sounds/navigate.wav`. Поддерживаемые расширения в порядке поиска: `.ogg`, `.oga`, `.wav`, `.mp3`, `.flac`, `.opus`, `.m4a`, `.aac` и `.webm`. Доступность кодека при воспроизведении зависит от установленного в системе backend Qt Multimedia. +Используйте несжатые PCM WAV-файлы, например `sounds/navigate.wav`. Другие аудиоформаты не поддерживаются. -Звуки наследуются отдельно для каждого события. Дочерняя тема может переопределить только `navigate`; отсутствующие события ищутся по цепочке родительских тем вплоть до `standart`. Если в одной теме для события есть несколько поддерживаемых расширений, используется первое расширение из списка выше. +Звуки наследуются отдельно для каждого события. Дочерняя тема может переопределить только `navigate`; отсутствующие события ищутся по цепочке родительских тем вплоть до `standart`. При проверке темы проверяются расширение, размер и сигнатура формата каждого звука. Файлы размером более 20 МиБ, символические ссылки, файлы за пределами директории темы и файлы, содержимое которых не соответствует расширению, отклоняются. Добавляйте лицензию и требуемую атрибуцию для каждого звукового ресурса, распространяемого вместе с темой. diff --git a/portprotonqt/sound_manager.py b/portprotonqt/sound_manager.py index 6a5e377b..378a1741 100644 --- a/portprotonqt/sound_manager.py +++ b/portprotonqt/sound_manager.py @@ -1,54 +1,56 @@ """Sound manager for UI feedback sounds, integrated with the theme system.""" +import sys from pathlib import Path -from PySide6.QtCore import QLoggingCategory, QUrl +from PySide6.QtCore import QUrl, qInstallMessageHandler from portprotonqt.config import ui_config from portprotonqt.logger import get_logger -from portprotonqt.theme_security import SUPPORTED_SOUND_EXTENSIONS, is_safe_sound_file +from portprotonqt.theme_security import is_safe_sound_file logger = get_logger(__name__) -FFMPEG_LOG_RULES = "qt.multimedia.ffmpeg=false\nqt.multimedia.ffmpeg.*=false" - SOUND_EVENTS = frozenset({ "navigate", "click", "confirm", "back", "toggle", "open", "keyboard_key", "tab_switch", "game_launch", "gamepad_connect", }) +MISSING_MEDIA_BACKEND_WARNING = "No QtMultimedia backends found." + + +def _multimedia_message_handler(message_type: object, context: object, message: str) -> None: + if message.startswith(MISSING_MEDIA_BACKEND_WARNING): + return + print(message, file=sys.stderr) class _SoundSlot: """A single player slot that pre-loads a sound file.""" def __init__(self) -> None: - from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer - - self._audio = QAudioOutput() - self._player = QMediaPlayer() - self._player.setAudioOutput(self._audio) - self._player.setLoops(QMediaPlayer.Loops.Once) - self._player.mediaStatusChanged.connect(self._handle_media_status) + previous_handler = qInstallMessageHandler(_multimedia_message_handler) + try: + from PySide6.QtMultimedia import QSoundEffect + self._effect = QSoundEffect() + finally: + qInstallMessageHandler(previous_handler) + self._effect.setLoopCount(1) self._loaded_event: str | None = None - def _handle_media_status(self, status: object) -> None: - if status == self._player.MediaStatus.EndOfMedia: - self._player.stop() - def play(self, event: str, url: QUrl) -> None: if ( self._loaded_event == event - and self._player.playbackState() == self._player.PlaybackState.PlayingState + and self._effect.isPlaying() ): return if self._loaded_event != event: - self._player.setSource(url) + self._effect.setSource(url) self._loaded_event = event - self._player.stop() - self._player.setPosition(0) - self._player.play() + self._effect.stop() + self._effect.play() def reset(self) -> None: - self._player.setSource(QUrl()) + self._effect.stop() + self._effect.setSource(QUrl()) self._loaded_event = None @@ -67,7 +69,6 @@ class SoundManager: if self._initialized: return self._initialized = True - QLoggingCategory.setFilterRules(FFMPEG_LOG_RULES) self._enabled = ui_config.get_sounds_enabled() self._sounds_dirs: list[str] = [] try: @@ -92,10 +93,9 @@ class SoundManager: if event not in SOUND_EVENTS: return None for sounds_dir in self._sounds_dirs: - for extension in SUPPORTED_SOUND_EXTENSIONS: - path = Path(sounds_dir) / f"{event}{extension}" - if path.is_file() and is_safe_sound_file(str(path)): - return path + path = Path(sounds_dir) / f"{event}.wav" + if path.is_file() and is_safe_sound_file(str(path)): + return path return None def _get_url(self, event: str) -> QUrl | None: diff --git a/portprotonqt/theme_security.py b/portprotonqt/theme_security.py index fc553f3f..3aba7dbb 100644 --- a/portprotonqt/theme_security.py +++ b/portprotonqt/theme_security.py @@ -12,9 +12,6 @@ logger = get_logger(__name__) MAX_THEME_PY_FILE_SIZE = 512 * 1024 MAX_AST_NODES = 20000 MAX_SOUND_FILE_SIZE = 20 * 1024 * 1024 -SUPPORTED_SOUND_EXTENSIONS = ( - ".ogg", ".oga", ".wav", ".mp3", ".flac", ".opus", ".m4a", ".aac", ".webm", -) class ThemeSecurityChecker: @@ -572,7 +569,7 @@ def check_theme_directory_safety(theme_dir: str, allow_absolute_imports: bool = elif filename.endswith((".ttf", ".otf")): if not is_safe_font_file(file_path): return False - elif filename.lower().endswith(SUPPORTED_SOUND_EXTENSIONS): + elif filename.lower().endswith(".wav"): if not is_safe_sound_file(file_path): return False @@ -617,7 +614,7 @@ def is_safe_font_file(file_path: str) -> bool: def is_safe_sound_file(file_path: str) -> bool: """Check a theme sound extension, size, and file signature.""" _, ext = os.path.splitext(file_path.lower()) - if ext not in SUPPORTED_SOUND_EXTENSIONS: + if ext != ".wav": logger.error("Unsafe sound file extension for %s: %s", file_path, ext) return False try: @@ -632,22 +629,8 @@ def is_safe_sound_file(file_path: str) -> bool: return False is_valid = False - if ext in (".ogg", ".oga", ".opus"): - is_valid = header.startswith(b"OggS") - elif ext == ".wav": + if ext == ".wav": is_valid = header.startswith(b"RIFF") and header[8:12] == b"WAVE" - elif ext == ".flac": - is_valid = header.startswith(b"fLaC") - elif ext == ".mp3": - is_valid = header.startswith(b"ID3") or ( - len(header) > 1 and header[0] == 0xff and header[1] & 0xe0 == 0xe0 - ) - elif ext == ".m4a": - is_valid = header[4:8] == b"ftyp" - elif ext == ".aac": - is_valid = len(header) > 1 and header[0] == 0xff and header[1] & 0xf0 == 0xf0 - elif ext == ".webm": - is_valid = header.startswith(b"\x1a\x45\xdf\xa3") if not is_valid: logger.error("Sound file %s has invalid signature", file_path) return is_valid diff --git a/portprotonqt/themes/standart/sounds/back.ogg b/portprotonqt/themes/standart/sounds/back.ogg deleted file mode 100644 index 9132b793..00000000 Binary files a/portprotonqt/themes/standart/sounds/back.ogg and /dev/null differ diff --git a/portprotonqt/themes/standart/sounds/back.wav b/portprotonqt/themes/standart/sounds/back.wav new file mode 100644 index 00000000..bee4a4a5 Binary files /dev/null and b/portprotonqt/themes/standart/sounds/back.wav differ diff --git a/portprotonqt/themes/standart/sounds/confirm.ogg b/portprotonqt/themes/standart/sounds/confirm.ogg deleted file mode 100644 index eace9aca..00000000 Binary files a/portprotonqt/themes/standart/sounds/confirm.ogg and /dev/null differ diff --git a/portprotonqt/themes/standart/sounds/confirm.wav b/portprotonqt/themes/standart/sounds/confirm.wav new file mode 100644 index 00000000..044a7c2a Binary files /dev/null and b/portprotonqt/themes/standart/sounds/confirm.wav differ diff --git a/portprotonqt/themes/standart/sounds/navigate.ogg b/portprotonqt/themes/standart/sounds/navigate.ogg deleted file mode 100644 index 837e4865..00000000 Binary files a/portprotonqt/themes/standart/sounds/navigate.ogg and /dev/null differ diff --git a/portprotonqt/themes/standart/sounds/navigate.wav b/portprotonqt/themes/standart/sounds/navigate.wav new file mode 100644 index 00000000..debf0ffe Binary files /dev/null and b/portprotonqt/themes/standart/sounds/navigate.wav differ diff --git a/portprotonqt/themes/standart/sounds/open.ogg b/portprotonqt/themes/standart/sounds/open.ogg deleted file mode 100644 index 47373e0a..00000000 Binary files a/portprotonqt/themes/standart/sounds/open.ogg and /dev/null differ diff --git a/portprotonqt/themes/standart/sounds/open.wav b/portprotonqt/themes/standart/sounds/open.wav new file mode 100644 index 00000000..f45e045d Binary files /dev/null and b/portprotonqt/themes/standart/sounds/open.wav differ diff --git a/tests/test_sound_manager.py b/tests/test_sound_manager.py index c336c951..2d8e99d7 100644 --- a/tests/test_sound_manager.py +++ b/tests/test_sound_manager.py @@ -49,15 +49,16 @@ def test_sound_manager_reuses_slot_for_same_event() -> None: assert manager._slot_index == 1 -def test_sound_slot_stops_at_end_of_media() -> None: - end_of_media = object() +def test_sound_slot_restarts_loaded_effect() -> None: slot: Any = object.__new__(_SoundSlot) - slot._player = Mock() - slot._player.MediaStatus.EndOfMedia = end_of_media + slot._effect = Mock() + slot._effect.isPlaying.return_value = False + slot._loaded_event = "navigate" - slot._handle_media_status(end_of_media) + slot.play("navigate", Mock()) - slot._player.stop.assert_called_once() + slot._effect.stop.assert_called_once() + slot._effect.play.assert_called_once() def test_sound_manager_skips_unsafe_sound_variant(tmp_path: Path) -> None: diff --git a/tests/test_theme_security.py b/tests/test_theme_security.py index d27d8dc4..c8e666fe 100644 --- a/tests/test_theme_security.py +++ b/tests/test_theme_security.py @@ -376,6 +376,22 @@ class TestDirectorySafety: (d / "styles.py").write_text("color = \"red\"\n") assert check_theme_directory_safety(str(d)) + def test_valid_wav_in_theme_dir(self, tmp_path: Path) -> None: + sounds = tmp_path / "theme" / "sounds" + sounds.mkdir(parents=True) + (sounds / "navigate.wav").write_bytes( + b"RIFF\x00\x00\x00\x00WAVE" + b"\x00" * 32 + ) + + assert check_theme_directory_safety(str(tmp_path / "theme")) + + def test_invalid_wav_in_theme_dir_is_rejected(self, tmp_path: Path) -> None: + sounds = tmp_path / "theme" / "sounds" + sounds.mkdir(parents=True) + (sounds / "navigate.wav").write_bytes(b"not audio") + + assert not check_theme_directory_safety(str(tmp_path / "theme")) + # === Font safety === @@ -398,32 +414,21 @@ class TestFontSafety: class TestSoundSafety: - @pytest.mark.parametrize( - ("extension", "header"), - [ - (".ogg", b"OggS"), - (".oga", b"OggS"), - (".opus", b"OggS"), - (".wav", b"RIFF\x00\x00\x00\x00WAVE"), - (".mp3", b"ID3"), - (".flac", b"fLaC"), - (".m4a", b"\x00\x00\x00\x10ftyp"), - (".aac", b"\xff\xf1"), - (".webm", b"\x1a\x45\xdf\xa3"), - ], - ) - def test_supported_signatures( - self, tmp_path: Path, extension: str, header: bytes, - ) -> None: - sound = tmp_path / f"sound{extension}" - sound.write_bytes(header + b"\x00" * 32) + def test_supported_wav_signature(self, tmp_path: Path) -> None: + sound = tmp_path / "sound.wav" + sound.write_bytes(b"RIFF\x00\x00\x00\x00WAVE" + b"\x00" * 32) assert is_safe_sound_file(str(sound)) def test_rejects_disguised_sound(self, tmp_path: Path) -> None: - sound = tmp_path / "sound.ogg" + sound = tmp_path / "sound.wav" sound.write_bytes(b"not audio") assert not is_safe_sound_file(str(sound)) + def test_rejects_non_wav_extension(self, tmp_path: Path) -> None: + sound = tmp_path / "sound.ogg" + sound.write_bytes(b"OggS" + b"\x00" * 32) + assert not is_safe_sound_file(str(sound)) + # === Image safety ===