Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 52 additions & 5 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -176,6 +222,7 @@ jobs:
fail_on_unmatched_files: true
files: |
**/friture*.msi
**/friture*.msix
**/friture*.dmg
**/friture*.AppImage
**/friture*.AppImage.zsync
Expand Down
14 changes: 8 additions & 6 deletions .github/workflows/install-windows.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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"
34 changes: 26 additions & 8 deletions friture/audiobackend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand All @@ -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:
Expand Down Expand Up @@ -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 = []
Expand All @@ -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]

Expand Down
23 changes: 15 additions & 8 deletions friture/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
18 changes: 10 additions & 8 deletions installer/appxmanifest.xml
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!--

To build an appx package to publish to the Microsoft store:
To build an MSIX package to publish to the Microsoft Store:

1. enable Windows developer mode (to allow side-loading)
1. enable Windows developer mode (to allow side-loading of an unsigned package)
2. the build output is in a friture-bin folder
3. friture-bin should contain appxmanifest.xml
4. in that folder, run: Add-AppxPackage -Register .\appxmanifest.xml
3. friture-bin should contain AppxManifest.xml
4. in that folder, run: Add-AppxPackage -Register .\AppxManifest.xml
5. app should start from Windows Start menu
- to deploy again, increase the version
6. from the parent folder, run:
PS C:\...\friture> MakeAppx pack /v /d .\friture-bin /p friture.appx
7. friture.appx file can be uploaded in a new submission to the Windows Partern Center
PS C:\...\friture> MakeAppx pack /v /d .\friture-bin /p friture.msix
7. friture.msix can be uploaded in a new submission to the Microsoft Partner Center
(this CI build produces an unsigned MSIX;
the Store signs the package on ingestion).

References:
https://docs.microsoft.com/en-us/windows/msix/packaging-tool/create-app-package
Expand All @@ -25,7 +27,7 @@ https://docs.microsoft.com/en-us/windows/msix/package/create-app-package-with-ma
Name="53504SilentGain.Friture"
Version="0.0.0.0"
Publisher="CN=74EE87F8-B2A0-400A-A66A-377F7F4E3BBE"
ProcessorArchitecture="x86" />
ProcessorArchitecture="x64" />
<Properties>
<DisplayName>Friture</DisplayName>
<PublisherDisplayName>Silent Gain</PublisherDisplayName>
Expand All @@ -37,7 +39,7 @@ https://docs.microsoft.com/en-us/windows/msix/package/create-app-package-with-ma
<Resource Language="en-us" />
</Resources>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.14393.0" MaxVersionTested="10.0.14393.0" />
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
</Dependencies>
<Capabilities>
<rescap:Capability Name="runFullTrust"/>
Expand Down
23 changes: 18 additions & 5 deletions scripts/smoke_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,20 @@

TIMEOUT_SECONDS = 20


def _kill_proc(proc):
"""Kill a launched process, cross-platform.

On POSIX we started the process in its own session (start_new_session=True)
and kill the whole process group so a spawned helper child cannot keep the
stdout/stderr pipes open and make communicate() hang. Windows has no
process groups, so we just TerminateProcess the leader.
"""
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except (AttributeError, ProcessLookupError, PermissionError):
proc.kill()

# Substrings that, if found in the log or stderr, mean the bundle is broken
# (a missing Qt module, shared library, or Python extension). We include the
# Python traceback header and the app's own unhandled-exception log line so a
Expand Down Expand Up @@ -122,13 +136,12 @@ def main():
rc = proc.returncode
except subprocess.TimeoutExpired:
timed_out = True
# kill the entire process group, not just the leader
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
proc.kill()
_kill_proc(proc)
stdout, stderr = proc.communicate()
rc = proc.returncode
except KeyboardInterrupt:
_kill_proc(proc)
raise

elapsed = time.time() - started

Expand Down
Loading