Skip to content

Commit ed47917

Browse files
authored
feat(windows): ship MSIX instead of appx; add headless smoke test (#451)
* windows: ship MSIX instead of appx; add headless smoke test - installer/appxmanifest.xml: ProcessorArchitecture amd64 (was x86, wrong for the 64-bit PyInstaller build), newer Windows 10 min/max baseline, updated comments to reflect MSIX. - install-windows.ps1: emit friture-$version.msix via MakeAppx (was .appx), save the manifest as AppxManifest.xml (MSIX canonical name). - build.yml: add 'Verify app launches' smoke-test step for Windows (frozen dist/friture/friture.exe, offscreen Qt, full-init assertion via the cross-platform smoke_test.py), a platformdirs-based log capture/upload, switch the artifact/upload from .appx to .msix, and add MSIX to the Store release files. (Store ingestion signs the package; CI builds an unsigned MSIX for packaging validation only.) * smoke_test: cross-platform timeout kill (Windows needs proc.kill fallback) os.killpg/signal.SIGKILL are POSIX-only; on Windows they raise AttributeError. Extract _kill_proc() that tries the process-group kill on POSIX and falls back to proc.kill() otherwise, so the Windows smoke test can terminate a hung release build instead of crashing the harness. * appxmanifest: ProcessorArchitecture x64 (MSIX schema forbids 'amd64') * audiobackend: tolerate no default output device at startup sounddevice.query_devices(kind='output') raises PortAudioError ('Error querying device -1') on a headless host with no default output device, which crashed AudioBackend() -> Friture() before init completed. Mirror the existing guard in get_input_devices() so a missing default output device degrades to an empty device list instead of raising. * audiobackend, settings: avoid startup crash with no audio device (headless) On a host with no default input/output device (e.g. the Windows CI runner, unlike Linux which loads a PulseAudio monitor source), the frozen app could not start: * get_readable_devices_list() / get_readable_output_devices_list() called sounddevice.query_devices(kind=...) unconditionally and, when it raised PortAudioError, logged via logger.exception() -- emitting a 'Traceback' line that trips the smoke test's FATAL_MARKERS. Guard the kind= calls and log at debug instead; mirror get_input_devices()'s early return when there are no devices. * SettingsDialog popped a blocking QMessageBox.critical() + sys.exit(1) on the empty device list. Under the offscreen Qt platform (the CI smoke test / any headless host) that modal blocks forever with no user to dismiss it, preventing init from reaching 'Init finished' and before any QML is shown. In headless mode, log a warning and continue with an empty device set instead; interactive desktop users keep the existing message+exit behaviour. Neither change affects machines that have a real audio input device. * settings: use QApplication.instance().platformName() (PyQt6 regressed the static call) PyQt6 moved QGuiApplication.platformName() from a static method to an instance method, so QtCore.QCoreApplication.platformName() raised AttributeError during SettingsDialog construction on a headless Windows runner, crashing Friture() before full init. Use the existing QApplication.instance() pattern (already used elsewhere in the codebase) to query the offscreen platform. * settings: don't crash on a stale/unknown saved themePreference at startup SettingsDialog.restoreState() did themeButtonGroup.button(id).setChecked(True) with the id read back from QSettings (AudioBackend group). On a system whose stored value is absent, stale, or outside {0,1,2}, button() returns None and QML/.setChecked raises AttributeError: 'NoneType' object has no attribute 'setChecked' -- crashing Friture() during restoreAppState() before 'Init finished'. This reproduces on a fresh Windows CI runner with no audio (unlike Linux, whose PulseAudio monitor source provides a default input): the headless no-device path runs SettingsDialog.__init__ (which returns early, leaving the comboBox empty) and restoreState is still invoked from the analyzer. Guard the button(id) lookups (both theme and input-type groups): if no button carries the stored id, fall back to the System (0) button instead of crashing. Real interactive users see no behaviour change (their stored 0/1/2 still maps to a button); only a bad/sentinel id is rescued. * settings: assign theme-button IDs before the no-audio early return Root cause of the Windows headless startup crash (now visible in the smoke log as 'No theme button for id 0'): SettingsDialog.__init__ early-returns in the no-input-device / offscreen branch, which on a headless Windows runner is the common path (unlike Linux, which loads a PulseAudio monitor source as its default input). That early return happened BEFORE the themeButtonGroup.setId(...) calls, so on the no-device path the button group had no IDs -- restoreState()'s button(0) returned None, and .setChecked() raised AttributeError. Move the idToggled connect + the three setId(0/1/2) calls to right after setupUi(), before the device-list early return, so the button group is always populated. The defensive None-guard in restoreState() stays as defense-in-depth. No behaviour change for interactive desktop users (their button IDs were already set). * Cleanup * Refine comment * Log PortAudio error details * Do not log the stacktrace to avoid confusing the smoke test
1 parent c18356d commit ed47917

6 files changed

Lines changed: 129 additions & 40 deletions

File tree

.github/workflows/build.yml

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,42 @@ jobs:
4141
shell: powershell
4242
run: ./.github/workflows/install-windows.ps1
4343

44-
- name: Upload friture appx
44+
- name: Verify app launches (smoke test)
45+
# Launch the frozen friture.exe headless (offscreen Qt) and assert it
46+
# reaches full init. The MSIX itself is not installed here (it is
47+
# unsigned; the Store signs on ingestion) -- we validate the same frozen
48+
# binary the MSIX wraps.
49+
shell: bash
50+
run: uv run python scripts/smoke_test.py dist/friture/friture.exe --no-splash
51+
52+
- name: Capture Friture log
53+
if: always()
54+
shell: bash
55+
run: |
56+
uv run python - <<'PY'
57+
import os, shutil, platformdirs
58+
src = os.path.join(platformdirs.user_log_dir("Friture", ""), "friture.log.txt")
59+
dst = "friture-smoke.log"
60+
if os.path.exists(src):
61+
shutil.copy2(src, dst)
62+
else:
63+
with open(dst, "w") as f:
64+
f.write("log not found: " + src)
65+
PY
66+
67+
- name: Upload friture smoke-test log
68+
if: always()
69+
uses: actions/upload-artifact@v7
70+
with:
71+
name: friture-smoke-log-windows
72+
path: friture-smoke.log
73+
if-no-files-found: warn
74+
75+
- name: Upload friture msix
4576
uses: actions/upload-artifact@v7
4677
with:
47-
name: friture-appx
48-
path: dist/friture-*.appx
78+
name: friture-msix
79+
path: dist/friture-*.msix
4980
if-no-files-found: error
5081

5182
- name: Upload friture msi
@@ -105,12 +136,27 @@ jobs:
105136
./friture-*.AppImage --appimage-extract
106137
uv run python scripts/smoke_test.py ./squashfs-root/AppRun --no-splash
107138
139+
- name: Capture Friture log
140+
if: always()
141+
shell: bash
142+
run: |
143+
uv run python - <<'PY'
144+
import os, shutil, platformdirs
145+
src = os.path.join(platformdirs.user_log_dir("Friture", ""), "friture.log.txt")
146+
dst = "friture-smoke.log"
147+
if os.path.exists(src):
148+
shutil.copy2(src, dst)
149+
else:
150+
with open(dst, "w") as f:
151+
f.write("log not found: " + src)
152+
PY
153+
108154
- name: Upload AppImage smoke-test log
109155
if: always()
110156
uses: actions/upload-artifact@v7
111157
with:
112-
name: friture-smoke-log
113-
path: ~/.local/state/Friture/log/friture.log.txt
158+
name: friture-smoke-log-linux
159+
path: friture-smoke.log
114160
if-no-files-found: warn
115161

116162
- name: Upload appImage
@@ -176,6 +222,7 @@ jobs:
176222
fail_on_unmatched_files: true
177223
files: |
178224
**/friture*.msi
225+
**/friture*.msix
179226
**/friture*.dmg
180227
**/friture*.AppImage
181228
**/friture*.AppImage.zsync

.github/workflows/install-windows.ps1

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,18 +44,20 @@ Write-Host "==========================================="
4444

4545
Write-Host ""
4646
Write-Host "==========================================="
47-
Write-Host "Build appx package"
47+
Write-Host "Build MSIX package"
4848
Write-Host "==========================================="
4949

50-
Copy-Item -Path .\dist\friture -Destination .\dist\friture-appx -Recurse
51-
Copy-Item -Path resources\images\friture.iconset\icon_512x512.png -Destination .\dist\friture-appx\icon_512x512.png
50+
Copy-Item -Path .\dist\friture -Destination .\dist\friture-msix -Recurse
51+
Copy-Item -Path resources\images\friture.iconset\icon_512x512.png -Destination .\dist\friture-msix\icon_512x512.png
5252

53-
# apply version to appxmanifest.xml and save it to the dist folder
53+
# apply version to AppxManifest.xml and save it to the package folder.
5454
$xml = [xml](Get-Content .\installer\appxmanifest.xml)
5555
$ns = New-Object System.Xml.XmlNamespaceManager($xml.NameTable)
5656
$ns.AddNamespace("ns", $xml.DocumentElement.NamespaceURI)
5757
$package = $xml.SelectSingleNode("//ns:Package", $ns)
5858
$package.Identity.Version = "$version.0.0"
59-
$xml.Save(".\dist\friture-appx\appxmanifest.xml")
59+
$xml.Save(".\dist\friture-msix\AppxManifest.xml")
6060

61-
MakeAppx pack /v /d .\dist\friture-appx /p ".\dist\friture-$version.appx"
61+
# SignTool is omitted here on purpose,
62+
# as Microsoft Store ingestion signs the final package.
63+
MakeAppx pack /v /d .\dist\friture-msix /p ".\dist\friture-$version.msix"

friture/audiobackend.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,8 @@ def get_readable_devices_list(self):
141141
try:
142142
default_input_device = sounddevice.query_devices(kind='input')
143143
default_input_device['index'] = raw_devices.index(default_input_device)
144-
except sounddevice.PortAudioError:
145-
self.logger.exception("Failed to query the default input device")
144+
except sounddevice.PortAudioError as err:
145+
self.logger.warning(f"Failed to query the default input device: {err}")
146146
default_input_device = None
147147

148148
devices_list = []
@@ -166,9 +166,20 @@ def get_readable_devices_list(self):
166166
def get_readable_output_devices_list(self):
167167
output_devices = self.get_output_devices()
168168

169+
# if there are no output devices at all,
170+
# sounddevice.query_devices(kind='output') raises PortAudioError ("Error
171+
# querying device -1").
172+
# Degrade to an empty list.
173+
if len(output_devices) == 0:
174+
return []
175+
169176
raw_devices = sounddevice.query_devices()
170-
default_output_device = sounddevice.query_devices(kind='output')
171-
default_output_device['index'] = raw_devices.index(default_output_device)
177+
try:
178+
default_output_device = sounddevice.query_devices(kind='output')
179+
default_output_device['index'] = raw_devices.index(default_output_device)
180+
except sounddevice.PortAudioError as err:
181+
self.logger.warning(f"No default output device available: {err}")
182+
default_output_device = None
172183

173184
devices_list = []
174185
for device in output_devices:
@@ -218,8 +229,8 @@ def get_input_devices(self):
218229

219230
try:
220231
default_input_device = sounddevice.query_devices(kind='input')
221-
except sounddevice.PortAudioError:
222-
self.logger.exception("Failed to query the default input device")
232+
except sounddevice.PortAudioError as err:
233+
self.logger.exception(f"Failed to query the default input device: {err}")
223234
default_input_device = None
224235

225236
input_devices = []
@@ -243,11 +254,18 @@ def get_input_devices(self):
243254
def get_output_devices(self):
244255
devices = sounddevice.query_devices()
245256

246-
default_output_device = sounddevice.query_devices(kind='output')
257+
# sounddevice.query_devices(kind='output') raises PortAudioError when
258+
# there is no default output device.
259+
# Degrade gracefully instead.
260+
try:
261+
default_output_device = sounddevice.query_devices(kind='output')
262+
except sounddevice.PortAudioError as err:
263+
self.logger.warning(f"No default output device available: {err}")
264+
default_output_device = None
247265

248266
output_devices = []
249267
if default_output_device is not None:
250-
# start by the default input device
268+
# start by the default output device
251269
default_output_device['index'] = devices.index(default_output_device)
252270
output_devices += [default_output_device]
253271

friture/settings.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,24 @@ def __init__(self, parent, toolbar_view_model: MainToolbarViewModel):
5252
# Setup the user interface
5353
self.setupUi(self)
5454

55+
self.themeButtonGroup.idToggled.connect(self.theme_preference_toggled)
56+
# Set explicit IDs for theme buttons to match ThemeManager enum values
57+
# 0 = System (Unknown), 1 = Light, 2 = Dark
58+
self.themeButtonGroup.setId(self.radioButton_themeSystem, 0)
59+
self.themeButtonGroup.setId(self.radioButton_themeLight, 1)
60+
self.themeButtonGroup.setId(self.radioButton_themeDark, 2)
61+
5562
devices = AudioBackend().get_readable_devices_list()
5663

5764
if devices == []:
58-
# no audio input device: display a message and exit
65+
# no audio input device
66+
if QtWidgets.QApplication.instance().platformName() == "offscreen":
67+
# Headless (e.g. the CI smoke test).
68+
# Log and continue with an empty device set instead of exiting,
69+
# for validation purposes.
70+
self.logger.warning("No audio input device available; continuing in headless mode")
71+
return
72+
# display a message and exit
5973
QtWidgets.QMessageBox.critical(self, no_input_device_title, no_input_device_message)
6074
QtCore.QTimer.singleShot(0, self.exitOnInit)
6175
sys.exit(1)
@@ -85,13 +99,6 @@ def __init__(self, parent, toolbar_view_model: MainToolbarViewModel):
8599
self.radioButton_duo.toggled.connect(self.duo_input_type_selected)
86100
self.checkbox_showPlayback.stateChanged.connect(self.show_playback_checkbox_changed)
87101
self.spinBox_historyLength.editingFinished.connect(self.history_length_edit_finished)
88-
self.themeButtonGroup.idToggled.connect(self.theme_preference_toggled)
89-
90-
# Set explicit IDs for theme buttons to match ThemeManager enum values
91-
# 0 = System (Unknown), 1 = Light, 2 = Dark
92-
self.themeButtonGroup.setId(self.radioButton_themeSystem, 0)
93-
self.themeButtonGroup.setId(self.radioButton_themeLight, 1)
94-
self.themeButtonGroup.setId(self.radioButton_themeDark, 2)
95102

96103
@pyqtProperty(bool, notify=show_playback_changed) # type: ignore
97104
def show_playback(self) -> bool:

installer/appxmanifest.xml

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
<?xml version="1.0" encoding="utf-8"?>
22
<!--
33
4-
To build an appx package to publish to the Microsoft store:
4+
To build an MSIX package to publish to the Microsoft Store:
55
6-
1. enable Windows developer mode (to allow side-loading)
6+
1. enable Windows developer mode (to allow side-loading of an unsigned package)
77
2. the build output is in a friture-bin folder
8-
3. friture-bin should contain appxmanifest.xml
9-
4. in that folder, run: Add-AppxPackage -Register .\appxmanifest.xml
8+
3. friture-bin should contain AppxManifest.xml
9+
4. in that folder, run: Add-AppxPackage -Register .\AppxManifest.xml
1010
5. app should start from Windows Start menu
1111
- to deploy again, increase the version
1212
6. from the parent folder, run:
13-
PS C:\...\friture> MakeAppx pack /v /d .\friture-bin /p friture.appx
14-
7. friture.appx file can be uploaded in a new submission to the Windows Partern Center
13+
PS C:\...\friture> MakeAppx pack /v /d .\friture-bin /p friture.msix
14+
7. friture.msix can be uploaded in a new submission to the Microsoft Partner Center
15+
(this CI build produces an unsigned MSIX;
16+
the Store signs the package on ingestion).
1517
1618
References:
1719
https://docs.microsoft.com/en-us/windows/msix/packaging-tool/create-app-package
@@ -25,7 +27,7 @@ https://docs.microsoft.com/en-us/windows/msix/package/create-app-package-with-ma
2527
Name="53504SilentGain.Friture"
2628
Version="0.0.0.0"
2729
Publisher="CN=74EE87F8-B2A0-400A-A66A-377F7F4E3BBE"
28-
ProcessorArchitecture="x86" />
30+
ProcessorArchitecture="x64" />
2931
<Properties>
3032
<DisplayName>Friture</DisplayName>
3133
<PublisherDisplayName>Silent Gain</PublisherDisplayName>
@@ -37,7 +39,7 @@ https://docs.microsoft.com/en-us/windows/msix/package/create-app-package-with-ma
3739
<Resource Language="en-us" />
3840
</Resources>
3941
<Dependencies>
40-
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.14393.0" MaxVersionTested="10.0.14393.0" />
42+
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
4143
</Dependencies>
4244
<Capabilities>
4345
<rescap:Capability Name="runFullTrust"/>

scripts/smoke_test.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,20 @@
3434

3535
TIMEOUT_SECONDS = 20
3636

37+
38+
def _kill_proc(proc):
39+
"""Kill a launched process, cross-platform.
40+
41+
On POSIX we started the process in its own session (start_new_session=True)
42+
and kill the whole process group so a spawned helper child cannot keep the
43+
stdout/stderr pipes open and make communicate() hang. Windows has no
44+
process groups, so we just TerminateProcess the leader.
45+
"""
46+
try:
47+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
48+
except (AttributeError, ProcessLookupError, PermissionError):
49+
proc.kill()
50+
3751
# Substrings that, if found in the log or stderr, mean the bundle is broken
3852
# (a missing Qt module, shared library, or Python extension). We include the
3953
# Python traceback header and the app's own unhandled-exception log line so a
@@ -122,13 +136,12 @@ def main():
122136
rc = proc.returncode
123137
except subprocess.TimeoutExpired:
124138
timed_out = True
125-
# kill the entire process group, not just the leader
126-
try:
127-
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
128-
except (ProcessLookupError, PermissionError):
129-
proc.kill()
139+
_kill_proc(proc)
130140
stdout, stderr = proc.communicate()
131141
rc = proc.returncode
142+
except KeyboardInterrupt:
143+
_kill_proc(proc)
144+
raise
132145

133146
elapsed = time.time() - started
134147

0 commit comments

Comments
 (0)