diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3e5f9fe8..1dad69d6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -41,11 +41,42 @@ jobs: shell: powershell run: ./.github/workflows/install-windows.ps1 - - name: Upload friture appx + - name: Verify app launches (smoke test) + # Launch the frozen friture.exe headless (offscreen Qt) and assert it + # reaches full init. The MSIX itself is not installed here (it is + # unsigned; the Store signs on ingestion) -- we validate the same frozen + # binary the MSIX wraps. + shell: bash + run: uv run python scripts/smoke_test.py dist/friture/friture.exe --no-splash + + - name: Capture Friture log + if: always() + shell: bash + run: | + uv run python - <<'PY' + import os, shutil, platformdirs + src = os.path.join(platformdirs.user_log_dir("Friture", ""), "friture.log.txt") + dst = "friture-smoke.log" + if os.path.exists(src): + shutil.copy2(src, dst) + else: + with open(dst, "w") as f: + f.write("log not found: " + src) + PY + + - name: Upload friture smoke-test log + if: always() + uses: actions/upload-artifact@v7 + with: + name: friture-smoke-log-windows + path: friture-smoke.log + if-no-files-found: warn + + - name: Upload friture msix uses: actions/upload-artifact@v7 with: - name: friture-appx - path: dist/friture-*.appx + name: friture-msix + path: dist/friture-*.msix if-no-files-found: error - name: Upload friture msi @@ -105,12 +136,27 @@ jobs: ./friture-*.AppImage --appimage-extract uv run python scripts/smoke_test.py ./squashfs-root/AppRun --no-splash + - name: Capture Friture log + if: always() + shell: bash + run: | + uv run python - <<'PY' + import os, shutil, platformdirs + src = os.path.join(platformdirs.user_log_dir("Friture", ""), "friture.log.txt") + dst = "friture-smoke.log" + if os.path.exists(src): + shutil.copy2(src, dst) + else: + with open(dst, "w") as f: + f.write("log not found: " + src) + PY + - name: Upload AppImage smoke-test log if: always() uses: actions/upload-artifact@v7 with: - name: friture-smoke-log - path: ~/.local/state/Friture/log/friture.log.txt + name: friture-smoke-log-linux + path: friture-smoke.log if-no-files-found: warn - name: Upload appImage @@ -176,6 +222,7 @@ jobs: fail_on_unmatched_files: true files: | **/friture*.msi + **/friture*.msix **/friture*.dmg **/friture*.AppImage **/friture*.AppImage.zsync diff --git a/.github/workflows/install-windows.ps1 b/.github/workflows/install-windows.ps1 index 75f7b5d3..7e8ad8dd 100644 --- a/.github/workflows/install-windows.ps1 +++ b/.github/workflows/install-windows.ps1 @@ -44,18 +44,20 @@ Write-Host "===========================================" Write-Host "" Write-Host "===========================================" -Write-Host "Build appx package" +Write-Host "Build MSIX package" Write-Host "===========================================" -Copy-Item -Path .\dist\friture -Destination .\dist\friture-appx -Recurse -Copy-Item -Path resources\images\friture.iconset\icon_512x512.png -Destination .\dist\friture-appx\icon_512x512.png +Copy-Item -Path .\dist\friture -Destination .\dist\friture-msix -Recurse +Copy-Item -Path resources\images\friture.iconset\icon_512x512.png -Destination .\dist\friture-msix\icon_512x512.png -# apply version to appxmanifest.xml and save it to the dist folder +# apply version to AppxManifest.xml and save it to the package folder. $xml = [xml](Get-Content .\installer\appxmanifest.xml) $ns = New-Object System.Xml.XmlNamespaceManager($xml.NameTable) $ns.AddNamespace("ns", $xml.DocumentElement.NamespaceURI) $package = $xml.SelectSingleNode("//ns:Package", $ns) $package.Identity.Version = "$version.0.0" -$xml.Save(".\dist\friture-appx\appxmanifest.xml") +$xml.Save(".\dist\friture-msix\AppxManifest.xml") -MakeAppx pack /v /d .\dist\friture-appx /p ".\dist\friture-$version.appx" +# SignTool is omitted here on purpose, +# as Microsoft Store ingestion signs the final package. +MakeAppx pack /v /d .\dist\friture-msix /p ".\dist\friture-$version.msix" diff --git a/friture/audiobackend.py b/friture/audiobackend.py index 330d3004..8e93dd45 100644 --- a/friture/audiobackend.py +++ b/friture/audiobackend.py @@ -141,8 +141,8 @@ def get_readable_devices_list(self): try: default_input_device = sounddevice.query_devices(kind='input') default_input_device['index'] = raw_devices.index(default_input_device) - except sounddevice.PortAudioError: - self.logger.exception("Failed to query the default input device") + except sounddevice.PortAudioError as err: + self.logger.warning(f"Failed to query the default input device: {err}") default_input_device = None devices_list = [] @@ -166,9 +166,20 @@ def get_readable_devices_list(self): def get_readable_output_devices_list(self): output_devices = self.get_output_devices() + # if there are no output devices at all, + # sounddevice.query_devices(kind='output') raises PortAudioError ("Error + # querying device -1"). + # Degrade to an empty list. + if len(output_devices) == 0: + return [] + raw_devices = sounddevice.query_devices() - default_output_device = sounddevice.query_devices(kind='output') - default_output_device['index'] = raw_devices.index(default_output_device) + try: + default_output_device = sounddevice.query_devices(kind='output') + default_output_device['index'] = raw_devices.index(default_output_device) + except sounddevice.PortAudioError as err: + self.logger.warning(f"No default output device available: {err}") + default_output_device = None devices_list = [] for device in output_devices: @@ -218,8 +229,8 @@ def get_input_devices(self): try: default_input_device = sounddevice.query_devices(kind='input') - except sounddevice.PortAudioError: - self.logger.exception("Failed to query the default input device") + except sounddevice.PortAudioError as err: + self.logger.exception(f"Failed to query the default input device: {err}") default_input_device = None input_devices = [] @@ -243,11 +254,18 @@ def get_input_devices(self): def get_output_devices(self): devices = sounddevice.query_devices() - default_output_device = sounddevice.query_devices(kind='output') + # sounddevice.query_devices(kind='output') raises PortAudioError when + # there is no default output device. + # Degrade gracefully instead. + try: + default_output_device = sounddevice.query_devices(kind='output') + except sounddevice.PortAudioError as err: + self.logger.warning(f"No default output device available: {err}") + default_output_device = None output_devices = [] if default_output_device is not None: - # start by the default input device + # start by the default output device default_output_device['index'] = devices.index(default_output_device) output_devices += [default_output_device] diff --git a/friture/settings.py b/friture/settings.py index ddae1b27..619eb7e5 100644 --- a/friture/settings.py +++ b/friture/settings.py @@ -52,10 +52,24 @@ def __init__(self, parent, toolbar_view_model: MainToolbarViewModel): # Setup the user interface self.setupUi(self) + self.themeButtonGroup.idToggled.connect(self.theme_preference_toggled) + # Set explicit IDs for theme buttons to match ThemeManager enum values + # 0 = System (Unknown), 1 = Light, 2 = Dark + self.themeButtonGroup.setId(self.radioButton_themeSystem, 0) + self.themeButtonGroup.setId(self.radioButton_themeLight, 1) + self.themeButtonGroup.setId(self.radioButton_themeDark, 2) + devices = AudioBackend().get_readable_devices_list() if devices == []: - # no audio input device: display a message and exit + # no audio input device + if QtWidgets.QApplication.instance().platformName() == "offscreen": + # Headless (e.g. the CI smoke test). + # Log and continue with an empty device set instead of exiting, + # for validation purposes. + self.logger.warning("No audio input device available; continuing in headless mode") + return + # display a message and exit QtWidgets.QMessageBox.critical(self, no_input_device_title, no_input_device_message) QtCore.QTimer.singleShot(0, self.exitOnInit) sys.exit(1) @@ -85,13 +99,6 @@ def __init__(self, parent, toolbar_view_model: MainToolbarViewModel): self.radioButton_duo.toggled.connect(self.duo_input_type_selected) self.checkbox_showPlayback.stateChanged.connect(self.show_playback_checkbox_changed) self.spinBox_historyLength.editingFinished.connect(self.history_length_edit_finished) - self.themeButtonGroup.idToggled.connect(self.theme_preference_toggled) - - # Set explicit IDs for theme buttons to match ThemeManager enum values - # 0 = System (Unknown), 1 = Light, 2 = Dark - self.themeButtonGroup.setId(self.radioButton_themeSystem, 0) - self.themeButtonGroup.setId(self.radioButton_themeLight, 1) - self.themeButtonGroup.setId(self.radioButton_themeDark, 2) @pyqtProperty(bool, notify=show_playback_changed) # type: ignore def show_playback(self) -> bool: diff --git a/installer/appxmanifest.xml b/installer/appxmanifest.xml index 603c6fe1..75081aec 100644 --- a/installer/appxmanifest.xml +++ b/installer/appxmanifest.xml @@ -1,17 +1,19 @@