mini-eq 0.3.1__tar.gz → 0.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mini_eq-0.3.1 → mini_eq-0.3.2}/CHANGELOG.md +7 -0
- {mini_eq-0.3.1/src/mini_eq.egg-info → mini_eq-0.3.2}/PKG-INFO +1 -1
- {mini_eq-0.3.1 → mini_eq-0.3.2}/data/io.github.bhack.mini-eq.metainfo.xml +11 -2
- {mini_eq-0.3.1 → mini_eq-0.3.2}/docs/flathub.md +8 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/docs/release.md +28 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/pyproject.toml +1 -1
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/window.py +6 -1
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/wireplumber_backend.py +16 -2
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/wireplumber_stream_router.py +12 -4
- {mini_eq-0.3.1 → mini_eq-0.3.2/src/mini_eq.egg-info}/PKG-INFO +1 -1
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq.egg-info/SOURCES.txt +1 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_window.py +33 -2
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_wireplumber_backend.py +130 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_wireplumber_stream_router.py +46 -0
- mini_eq-0.3.2/tools/check_flatpak_runtime.py +290 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tools/check_wireplumber_gi.py +1 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/AGENTS.md +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/LICENSE +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/MANIFEST.in +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/README.md +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/SECURITY.md +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/data/io.github.bhack.mini-eq.desktop +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/docker/ubuntu-24.04-wp04.Dockerfile +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/docs/performance.md +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/docs/screenshots/README.md +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/docs/screenshots/mini-eq-dark.png +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/docs/screenshots/mini-eq.png +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/docs/social-preview.png +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/extensions/gnome-shell/README.md +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/extensions/gnome-shell/mini-eq@bhack.github.io/extension.js +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/extensions/gnome-shell/mini-eq@bhack.github.io/metadata.json +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/extensions/gnome-shell/mini-eq@bhack.github.io/mini-eq-symbolic.svg +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/flatpak/patches/wireplumber-0.5.14-tools-disabled-po.patch +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/io.github.bhack.mini-eq.yaml +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/python3-dependencies.yaml +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/setup.cfg +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/__init__.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/__main__.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/analyzer.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/analyzer_widget.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/app.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/appearance.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/assets/icons/hicolor/scalable/apps/io.github.bhack.mini-eq.svg +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/assets/icons/hicolor/symbolic/apps/io.github.bhack.mini-eq-symbolic.svg +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/band_fader.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/cli.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/core.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/dbus_control.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/deps.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/desktop_integration.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/ebur128.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/filter_chain.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/glib_utils.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/gtk_utils.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/instance.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/routing.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/screenshot.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/style.css +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/window_analyzer.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/window_graph.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/window_headroom.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/window_layout.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/window_presets.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/window_utility.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/window_utils.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq.egg-info/dependency_links.txt +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq.egg-info/entry_points.txt +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq.egg-info/requires.txt +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq.egg-info/top_level.txt +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_gnome_shell_extension.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_analyzer.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_app.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_appearance.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_cli.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_core.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_dbus_control.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_deps.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_desktop_integration.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_ebur128.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_filter_chain.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_instance.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_output_presets.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_routing.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_window_analyzer.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_window_graph.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_version_metadata.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tools/benchmark_fader_drag.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tools/check_flathub_manifest_drift.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tools/check_gnome_shell_extension.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tools/demo_runtime.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tools/gnome-shell-extension/fake_mini_eq_control.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tools/pack_gnome_shell_extension.sh +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tools/profile_live_app.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tools/profile_sysprof.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tools/release_post_publish.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tools/release_preflight.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tools/render_demo_screenshot.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tools/render_social_preview.py +0 -0
- {mini_eq-0.3.1 → mini_eq-0.3.2}/tools/run_gnome_extension_dev_shell.sh +0 -0
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.2 - 2026-05-04
|
|
4
|
+
|
|
5
|
+
- Fix Flatpak system-wide routing by requesting PipeWire manager access from the WirePlumber client.
|
|
6
|
+
- Restore partially routed streams when initial routing fails and reset the routing switch after failures.
|
|
7
|
+
- Avoid restarting output routing during GTK close shutdown.
|
|
8
|
+
- Add a local Flatpak runtime smoke test and an experimental manual CI job for routing checks.
|
|
9
|
+
|
|
3
10
|
## 0.3.1 - 2026-05-04
|
|
4
11
|
|
|
5
12
|
- Improve adaptive layout spacing across compact, tall, and wide windows.
|
|
@@ -34,11 +34,11 @@
|
|
|
34
34
|
</description>
|
|
35
35
|
<screenshots>
|
|
36
36
|
<screenshot type="default">
|
|
37
|
-
<image>https://raw.githubusercontent.com/bhack/mini-eq/v0.3.
|
|
37
|
+
<image>https://raw.githubusercontent.com/bhack/mini-eq/v0.3.2/docs/screenshots/mini-eq.png</image>
|
|
38
38
|
<caption>Adjust sound output with equalizer controls</caption>
|
|
39
39
|
</screenshot>
|
|
40
40
|
<screenshot>
|
|
41
|
-
<image>https://raw.githubusercontent.com/bhack/mini-eq/v0.3.
|
|
41
|
+
<image>https://raw.githubusercontent.com/bhack/mini-eq/v0.3.2/docs/screenshots/mini-eq-dark.png</image>
|
|
42
42
|
<caption>Use the equalizer with dark style</caption>
|
|
43
43
|
</screenshot>
|
|
44
44
|
</screenshots>
|
|
@@ -46,6 +46,15 @@
|
|
|
46
46
|
<url type="bugtracker">https://github.com/bhack/mini-eq/issues</url>
|
|
47
47
|
<url type="vcs-browser">https://github.com/bhack/mini-eq</url>
|
|
48
48
|
<releases>
|
|
49
|
+
<release version="0.3.2" date="2026-05-04">
|
|
50
|
+
<description>
|
|
51
|
+
<ul>
|
|
52
|
+
<li>Fix Flatpak system-wide routing access through WirePlumber.</li>
|
|
53
|
+
<li>Restore partially routed streams when initial routing fails.</li>
|
|
54
|
+
<li>Avoid restarting routing during window-close shutdown.</li>
|
|
55
|
+
</ul>
|
|
56
|
+
</description>
|
|
57
|
+
</release>
|
|
49
58
|
<release version="0.3.1" date="2026-05-04">
|
|
50
59
|
<description>
|
|
51
60
|
<ul>
|
|
@@ -126,6 +126,14 @@ flatpak run --command=flathub-build org.flatpak.Builder --install io.github.bhac
|
|
|
126
126
|
flatpak run io.github.bhack.mini-eq --check-deps
|
|
127
127
|
```
|
|
128
128
|
|
|
129
|
+
When routing, WirePlumber access, Flatpak permissions, runtime dependencies, or
|
|
130
|
+
shutdown behavior changed, also run the upstream runtime smoke test against the
|
|
131
|
+
installed local build before opening or merging the Flathub PR:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
python3 tools/check_flatpak_runtime.py --app-ref io.github.bhack.mini-eq//master
|
|
135
|
+
```
|
|
136
|
+
|
|
129
137
|
For a local source and full build check without installing the app:
|
|
130
138
|
|
|
131
139
|
```bash
|
|
@@ -94,6 +94,34 @@ If extensions.gnome.org asks for an extension screenshot, capture it from the
|
|
|
94
94
|
nested fake-control Shell documented in `extensions/gnome-shell/README.md` so no
|
|
95
95
|
real desktop, device, account, or path details are exposed.
|
|
96
96
|
|
|
97
|
+
## Flatpak Runtime Smoke
|
|
98
|
+
|
|
99
|
+
Run this after installing the local Flatpak build, and before release
|
|
100
|
+
publication or Flathub handoff, whenever PipeWire routing, WirePlumber access,
|
|
101
|
+
Flatpak permissions, runtime dependencies, or shutdown behavior changed. This
|
|
102
|
+
check temporarily routes a silent host PipeWire stream through Mini EQ and then
|
|
103
|
+
verifies that the stream is restored when the app exits, so keep it separate
|
|
104
|
+
from the generic preflight.
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
flatpak remote-add --if-not-exists --user flathub https://dl.flathub.org/repo/flathub.flatpakrepo
|
|
108
|
+
flatpak install --user -y flathub org.flatpak.Builder//stable
|
|
109
|
+
flatpak run --command=flathub-build org.flatpak.Builder --install io.github.bhack.mini-eq.yaml
|
|
110
|
+
python3 tools/check_flatpak_runtime.py --app-ref io.github.bhack.mini-eq//master
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
For shutdown changes, also run the installed Flatpak interactively, enable
|
|
114
|
+
system-wide EQ, close the GTK window, and confirm that the app exits without a
|
|
115
|
+
crash and streams are restored:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
flatpak run io.github.bhack.mini-eq//master --auto-route
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
There is also an experimental non-blocking GitHub Actions path for this check:
|
|
122
|
+
manually dispatch the `CI` workflow with `flatpak_runtime_smoke=true`. Treat it
|
|
123
|
+
as extra signal only until it has proven stable in the hosted runner.
|
|
124
|
+
|
|
97
125
|
## Build
|
|
98
126
|
|
|
99
127
|
```bash
|
|
@@ -352,7 +352,7 @@ class MiniEqWindow(
|
|
|
352
352
|
self.updating_ui = False
|
|
353
353
|
|
|
354
354
|
try:
|
|
355
|
-
self.controller.route_system_audio(False, announce=False)
|
|
355
|
+
self.controller.route_system_audio(False, announce=False, refresh_output=False)
|
|
356
356
|
except Exception:
|
|
357
357
|
pass
|
|
358
358
|
finally:
|
|
@@ -780,6 +780,11 @@ class MiniEqWindow(
|
|
|
780
780
|
self.controller.route_system_audio(enabled)
|
|
781
781
|
route_changed = True
|
|
782
782
|
except Exception as exc:
|
|
783
|
+
self.updating_ui = True
|
|
784
|
+
try:
|
|
785
|
+
switch.set_active(self.controller.routed)
|
|
786
|
+
finally:
|
|
787
|
+
self.updating_ui = False
|
|
783
788
|
self.set_status(str(exc))
|
|
784
789
|
finally:
|
|
785
790
|
self.update_eq_power_indicator()
|
|
@@ -12,6 +12,8 @@ SPA_ID_TYPE = "Spa:Id"
|
|
|
12
12
|
STREAM_OUTPUT_AUDIO = "Stream/Output/Audio"
|
|
13
13
|
AUDIO_SINK = "Audio/Sink"
|
|
14
14
|
FILTER_CHAIN_MODULE_NAME = "libpipewire-module-filter-chain"
|
|
15
|
+
PIPEWIRE_CLIENT_NAME = "Mini EQ"
|
|
16
|
+
PIPEWIRE_MEDIA_CATEGORY = "Manager"
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
@dataclass(frozen=True)
|
|
@@ -367,7 +369,15 @@ class WirePlumberBackend:
|
|
|
367
369
|
if not target.object_serial:
|
|
368
370
|
raise WirePlumberError(f"audio sink has no object.serial: {target_node_name}")
|
|
369
371
|
|
|
370
|
-
self.set_metadata_and_wait(stream.bound_id, TARGET_OBJECT_KEY, SPA_ID_TYPE, target.object_serial)
|
|
372
|
+
moved = self.set_metadata_and_wait(stream.bound_id, TARGET_OBJECT_KEY, SPA_ID_TYPE, target.object_serial)
|
|
373
|
+
if moved:
|
|
374
|
+
return
|
|
375
|
+
|
|
376
|
+
target_object, target_type = self.stream_target_object(stream.bound_id)
|
|
377
|
+
if target_object != target.object_serial or target_type not in {None, SPA_ID_TYPE}:
|
|
378
|
+
raise WirePlumberError(
|
|
379
|
+
f"WirePlumber did not acknowledge moving {stream.display_name} to {target_node_name}"
|
|
380
|
+
)
|
|
371
381
|
|
|
372
382
|
def stream_targets_node(self, stream_bound_id: int, target_node_name: str) -> bool:
|
|
373
383
|
target = self.audio_sink_by_name(target_node_name)
|
|
@@ -468,8 +478,12 @@ class WirePlumberBackend:
|
|
|
468
478
|
|
|
469
479
|
@staticmethod
|
|
470
480
|
def _new_core(Wp):
|
|
481
|
+
properties = Wp.Properties.new_empty()
|
|
482
|
+
properties.set("application.name", PIPEWIRE_CLIENT_NAME)
|
|
483
|
+
properties.set("media.category", PIPEWIRE_MEDIA_CATEGORY)
|
|
484
|
+
|
|
471
485
|
try:
|
|
472
|
-
return Wp.Core.new(None, None,
|
|
486
|
+
return Wp.Core.new(None, None, properties)
|
|
473
487
|
except TypeError:
|
|
474
488
|
return Wp.Core.new(None, None)
|
|
475
489
|
|
|
@@ -104,7 +104,7 @@ class WirePlumberStreamRouter:
|
|
|
104
104
|
self.routed_stream_ids.clear()
|
|
105
105
|
return restored
|
|
106
106
|
|
|
107
|
-
def refresh(self) -> bool:
|
|
107
|
+
def refresh(self, *, raise_errors: bool = False) -> bool:
|
|
108
108
|
if not self.enabled:
|
|
109
109
|
return False
|
|
110
110
|
|
|
@@ -114,6 +114,8 @@ class WirePlumberStreamRouter:
|
|
|
114
114
|
self.emit_status(f"routed {routed_now} stream(s) to {self.virtual_sink_name}")
|
|
115
115
|
except Exception as exc:
|
|
116
116
|
self.emit_status(f"routing warning: {exc}")
|
|
117
|
+
if raise_errors:
|
|
118
|
+
raise
|
|
117
119
|
|
|
118
120
|
return False
|
|
119
121
|
|
|
@@ -140,14 +142,14 @@ class WirePlumberStreamRouter:
|
|
|
140
142
|
if self.event_source_id == 0:
|
|
141
143
|
self.event_source_id = GLib.idle_add(self.on_stream_event_idle)
|
|
142
144
|
|
|
143
|
-
def start_monitoring(self) -> None:
|
|
145
|
+
def start_monitoring(self, *, require_initial_route: bool = False) -> None:
|
|
144
146
|
self.backend.connect()
|
|
145
147
|
self.accept_stream_events = True
|
|
146
148
|
|
|
147
149
|
if self.object_added_handler_id == 0:
|
|
148
150
|
self.object_added_handler_id = self.backend.connect_object_added(self.handle_object_added)
|
|
149
151
|
|
|
150
|
-
self.refresh()
|
|
152
|
+
self.refresh(raise_errors=require_initial_route)
|
|
151
153
|
|
|
152
154
|
def stop_monitoring(self) -> None:
|
|
153
155
|
self.accept_stream_events = False
|
|
@@ -162,7 +164,13 @@ class WirePlumberStreamRouter:
|
|
|
162
164
|
|
|
163
165
|
def enable(self) -> None:
|
|
164
166
|
self.enabled = True
|
|
165
|
-
|
|
167
|
+
try:
|
|
168
|
+
self.start_monitoring(require_initial_route=True)
|
|
169
|
+
except Exception:
|
|
170
|
+
self.enabled = False
|
|
171
|
+
self.stop_monitoring()
|
|
172
|
+
self.restore_output_streams()
|
|
173
|
+
raise
|
|
166
174
|
|
|
167
175
|
def disable(self, announce: bool = True) -> None:
|
|
168
176
|
if not self.enabled and not self.routed_stream_ids:
|
|
@@ -82,6 +82,7 @@ tests/test_mini_eq_wireplumber_stream_router.py
|
|
|
82
82
|
tests/test_version_metadata.py
|
|
83
83
|
tools/benchmark_fader_drag.py
|
|
84
84
|
tools/check_flathub_manifest_drift.py
|
|
85
|
+
tools/check_flatpak_runtime.py
|
|
85
86
|
tools/check_gnome_shell_extension.py
|
|
86
87
|
tools/check_wireplumber_gi.py
|
|
87
88
|
tools/demo_runtime.py
|
|
@@ -58,7 +58,9 @@ def test_begin_close_request_shutdown_restores_routing_before_delayed_quit(monke
|
|
|
58
58
|
updating_ui=False,
|
|
59
59
|
route_switch=FakeSwitch(True),
|
|
60
60
|
controller=SimpleNamespace(
|
|
61
|
-
route_system_audio=lambda enabled, announce=True: calls.append(
|
|
61
|
+
route_system_audio=lambda enabled, announce=True, refresh_output=True: calls.append(
|
|
62
|
+
("route", enabled, announce, refresh_output)
|
|
63
|
+
)
|
|
62
64
|
),
|
|
63
65
|
update_info_label=lambda: calls.append("info"),
|
|
64
66
|
update_status_summary=lambda: calls.append("summary"),
|
|
@@ -74,7 +76,7 @@ def test_begin_close_request_shutdown_restores_routing_before_delayed_quit(monke
|
|
|
74
76
|
assert fake_window.updating_ui is False
|
|
75
77
|
assert fake_window.close_finish_source_id == 321
|
|
76
78
|
assert calls == [
|
|
77
|
-
("route", False, False),
|
|
79
|
+
("route", False, False, False),
|
|
78
80
|
"info",
|
|
79
81
|
"summary",
|
|
80
82
|
("visible", False),
|
|
@@ -86,3 +88,32 @@ def test_begin_close_request_shutdown_restores_routing_before_delayed_quit(monke
|
|
|
86
88
|
|
|
87
89
|
assert fake_window.close_finish_source_id == 0
|
|
88
90
|
assert application.quit_count == 1
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_on_route_changed_resets_switch_when_routing_fails() -> None:
|
|
94
|
+
calls: list[object] = []
|
|
95
|
+
|
|
96
|
+
def fail_route(_enabled: bool) -> None:
|
|
97
|
+
raise RuntimeError("metadata permission denied")
|
|
98
|
+
|
|
99
|
+
fake_window = SimpleNamespace(
|
|
100
|
+
updating_ui=False,
|
|
101
|
+
controller=SimpleNamespace(
|
|
102
|
+
eq_enabled=True,
|
|
103
|
+
routed=False,
|
|
104
|
+
route_system_audio=fail_route,
|
|
105
|
+
),
|
|
106
|
+
update_eq_power_indicator=lambda: calls.append("power"),
|
|
107
|
+
update_info_label=lambda: calls.append("info"),
|
|
108
|
+
update_status_summary=lambda: calls.append("summary"),
|
|
109
|
+
update_focus_summary=lambda: calls.append("focus"),
|
|
110
|
+
set_status=lambda message: calls.append(("status", message)),
|
|
111
|
+
notify_control_state_changed=lambda: calls.append("notify"),
|
|
112
|
+
)
|
|
113
|
+
route_switch = FakeSwitch(True)
|
|
114
|
+
|
|
115
|
+
window.MiniEqWindow.on_route_changed(fake_window, route_switch, None)
|
|
116
|
+
|
|
117
|
+
assert route_switch.get_active() is False
|
|
118
|
+
assert fake_window.updating_ui is False
|
|
119
|
+
assert ("status", "metadata permission denied") in calls
|
|
@@ -53,6 +53,32 @@ class FakeWp:
|
|
|
53
53
|
ImplModule = FakeImplModule
|
|
54
54
|
|
|
55
55
|
|
|
56
|
+
class FakeProperties:
|
|
57
|
+
def __init__(self) -> None:
|
|
58
|
+
self.values: dict[str, str] = {}
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def new_empty(cls) -> FakeProperties:
|
|
62
|
+
return cls()
|
|
63
|
+
|
|
64
|
+
def set(self, key: str, value: str) -> None:
|
|
65
|
+
self.values[key] = value
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class FakeCore:
|
|
69
|
+
calls: list[tuple[object | None, object | None, FakeProperties | None]] = []
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def new(cls, context, conf, properties=None):
|
|
73
|
+
cls.calls.append((context, conf, properties))
|
|
74
|
+
return object()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class FakeCoreWp:
|
|
78
|
+
Core = FakeCore
|
|
79
|
+
Properties = FakeProperties
|
|
80
|
+
|
|
81
|
+
|
|
56
82
|
class FakeNodeProxy:
|
|
57
83
|
def __init__(self, bound_id: int, set_result: bool = True) -> None:
|
|
58
84
|
self.bound_id = bound_id
|
|
@@ -214,6 +240,20 @@ def test_node_classification_and_display_name() -> None:
|
|
|
214
240
|
assert stream.display_name == "spotify"
|
|
215
241
|
|
|
216
242
|
|
|
243
|
+
def test_new_core_requests_pipewire_manager_access() -> None:
|
|
244
|
+
FakeCore.calls = []
|
|
245
|
+
|
|
246
|
+
wp_backend.WirePlumberBackend._new_core(FakeCoreWp)
|
|
247
|
+
|
|
248
|
+
assert len(FakeCore.calls) == 1
|
|
249
|
+
_context, _conf, properties = FakeCore.calls[0]
|
|
250
|
+
assert properties is not None
|
|
251
|
+
assert properties.values == {
|
|
252
|
+
"application.name": wp_backend.PIPEWIRE_CLIENT_NAME,
|
|
253
|
+
"media.category": wp_backend.PIPEWIRE_MEDIA_CATEGORY,
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
|
|
217
257
|
def test_sync_core_removes_timeout_source_after_success() -> None:
|
|
218
258
|
core = FakeSyncCore()
|
|
219
259
|
glib = FakeSyncGLib(core)
|
|
@@ -263,6 +303,96 @@ def test_stream_targets_node_rejects_different_target_object_metadata() -> None:
|
|
|
263
303
|
assert backend.stream_targets_node(126, "alsa_output.test") is False
|
|
264
304
|
|
|
265
305
|
|
|
306
|
+
def test_move_stream_to_target_raises_when_metadata_change_is_not_acknowledged() -> None:
|
|
307
|
+
backend = wp_backend.WirePlumberBackend()
|
|
308
|
+
stream = wp_backend.WirePlumberNode(
|
|
309
|
+
bound_id=126,
|
|
310
|
+
object_serial="300",
|
|
311
|
+
media_class=wp_backend.STREAM_OUTPUT_AUDIO,
|
|
312
|
+
node_name="spotify",
|
|
313
|
+
node_description=None,
|
|
314
|
+
application_name="spotify",
|
|
315
|
+
node_dont_move=False,
|
|
316
|
+
)
|
|
317
|
+
sink = wp_backend.WirePlumberNode(
|
|
318
|
+
bound_id=39,
|
|
319
|
+
object_serial="67",
|
|
320
|
+
media_class=wp_backend.AUDIO_SINK,
|
|
321
|
+
node_name="alsa_output.test",
|
|
322
|
+
node_description="Test Sink",
|
|
323
|
+
application_name=None,
|
|
324
|
+
node_dont_move=False,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
backend.output_stream_by_bound_id = lambda _bound_id: stream
|
|
328
|
+
backend.audio_sink_by_name = lambda _name: sink
|
|
329
|
+
backend.set_metadata_and_wait = lambda *_args: False
|
|
330
|
+
backend.stream_target_object = lambda _bound_id: ("64", wp_backend.SPA_ID_TYPE)
|
|
331
|
+
|
|
332
|
+
with pytest.raises(wp_backend.WirePlumberError, match="did not acknowledge"):
|
|
333
|
+
backend.move_stream_to_target(126, "alsa_output.test")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def test_move_stream_to_target_skips_metadata_read_after_acknowledged_change() -> None:
|
|
337
|
+
backend = wp_backend.WirePlumberBackend()
|
|
338
|
+
stream = wp_backend.WirePlumberNode(
|
|
339
|
+
bound_id=126,
|
|
340
|
+
object_serial="300",
|
|
341
|
+
media_class=wp_backend.STREAM_OUTPUT_AUDIO,
|
|
342
|
+
node_name="spotify",
|
|
343
|
+
node_description=None,
|
|
344
|
+
application_name="spotify",
|
|
345
|
+
node_dont_move=False,
|
|
346
|
+
)
|
|
347
|
+
sink = wp_backend.WirePlumberNode(
|
|
348
|
+
bound_id=39,
|
|
349
|
+
object_serial="67",
|
|
350
|
+
media_class=wp_backend.AUDIO_SINK,
|
|
351
|
+
node_name="alsa_output.test",
|
|
352
|
+
node_description="Test Sink",
|
|
353
|
+
application_name=None,
|
|
354
|
+
node_dont_move=False,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
backend.output_stream_by_bound_id = lambda _bound_id: stream
|
|
358
|
+
backend.audio_sink_by_name = lambda _name: sink
|
|
359
|
+
backend.set_metadata_and_wait = lambda *_args: True
|
|
360
|
+
backend.stream_target_object = lambda _bound_id: (_ for _ in ()).throw(
|
|
361
|
+
AssertionError("acknowledged moves should not need metadata readback")
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
backend.move_stream_to_target(126, "alsa_output.test")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def test_move_stream_to_target_accepts_already_updated_metadata() -> None:
|
|
368
|
+
backend = wp_backend.WirePlumberBackend()
|
|
369
|
+
stream = wp_backend.WirePlumberNode(
|
|
370
|
+
bound_id=126,
|
|
371
|
+
object_serial="300",
|
|
372
|
+
media_class=wp_backend.STREAM_OUTPUT_AUDIO,
|
|
373
|
+
node_name="spotify",
|
|
374
|
+
node_description=None,
|
|
375
|
+
application_name="spotify",
|
|
376
|
+
node_dont_move=False,
|
|
377
|
+
)
|
|
378
|
+
sink = wp_backend.WirePlumberNode(
|
|
379
|
+
bound_id=39,
|
|
380
|
+
object_serial="67",
|
|
381
|
+
media_class=wp_backend.AUDIO_SINK,
|
|
382
|
+
node_name="alsa_output.test",
|
|
383
|
+
node_description="Test Sink",
|
|
384
|
+
application_name=None,
|
|
385
|
+
node_dont_move=False,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
backend.output_stream_by_bound_id = lambda _bound_id: stream
|
|
389
|
+
backend.audio_sink_by_name = lambda _name: sink
|
|
390
|
+
backend.set_metadata_and_wait = lambda *_args: False
|
|
391
|
+
backend.stream_target_object = lambda _bound_id: ("67", wp_backend.SPA_ID_TYPE)
|
|
392
|
+
|
|
393
|
+
backend.move_stream_to_target(126, "alsa_output.test")
|
|
394
|
+
|
|
395
|
+
|
|
266
396
|
def test_defaults_returns_cached_value_without_metadata_read(monkeypatch) -> None:
|
|
267
397
|
backend = wp_backend.WirePlumberBackend()
|
|
268
398
|
backend._cached_defaults = wp_backend.WirePlumberDefaults("cached.default", "cached.configured")
|
|
@@ -36,6 +36,7 @@ class FakeWirePlumberBackend:
|
|
|
36
36
|
self.closed = False
|
|
37
37
|
self.disconnected_handlers: list[int] = []
|
|
38
38
|
self.missing_stream_ids: set[int] = set()
|
|
39
|
+
self.move_failures: dict[int, Exception] = {}
|
|
39
40
|
|
|
40
41
|
def connect(self) -> None:
|
|
41
42
|
self.connected = True
|
|
@@ -49,6 +50,8 @@ class FakeWirePlumberBackend:
|
|
|
49
50
|
def move_stream_to_target(self, stream_bound_id: int, target_node_name: str) -> None:
|
|
50
51
|
if stream_bound_id in self.missing_stream_ids:
|
|
51
52
|
raise wp_backend.WirePlumberError(f"output stream not found: {stream_bound_id}")
|
|
53
|
+
if stream_bound_id in self.move_failures:
|
|
54
|
+
raise self.move_failures[stream_bound_id]
|
|
52
55
|
|
|
53
56
|
self.moves.append((stream_bound_id, target_node_name))
|
|
54
57
|
self.target_nodes[stream_bound_id] = target_node_name
|
|
@@ -151,6 +154,49 @@ def test_wireplumber_router_drops_stream_that_disappears_during_route() -> None:
|
|
|
151
154
|
assert router.routed_stream_ids == set()
|
|
152
155
|
|
|
153
156
|
|
|
157
|
+
def test_wireplumber_router_enable_raises_and_stops_monitoring_on_initial_route_error() -> None:
|
|
158
|
+
backend = FakeWirePlumberBackend(
|
|
159
|
+
[make_node(1, wp_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify")],
|
|
160
|
+
)
|
|
161
|
+
statuses: list[str] = []
|
|
162
|
+
router = wp_router.WirePlumberStreamRouter("mini_eq_sink", "mini_eq_sink_output", statuses.append, backend)
|
|
163
|
+
|
|
164
|
+
def fail_move(_stream_bound_id: int, _target_node_name: str) -> None:
|
|
165
|
+
raise RuntimeError("metadata permission denied")
|
|
166
|
+
|
|
167
|
+
backend.move_stream_to_target = fail_move
|
|
168
|
+
|
|
169
|
+
with pytest.raises(RuntimeError, match="metadata permission denied"):
|
|
170
|
+
router.enable()
|
|
171
|
+
|
|
172
|
+
assert router.enabled is False
|
|
173
|
+
assert router.accept_stream_events is False
|
|
174
|
+
assert backend.disconnected_handlers == [42]
|
|
175
|
+
assert statuses == ["routing warning: metadata permission denied"]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_wireplumber_router_enable_restores_partial_initial_route_failure() -> None:
|
|
179
|
+
backend = FakeWirePlumberBackend(
|
|
180
|
+
[
|
|
181
|
+
make_node(1, wp_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify"),
|
|
182
|
+
make_node(2, wp_backend.STREAM_OUTPUT_AUDIO, "browser", "Browser"),
|
|
183
|
+
]
|
|
184
|
+
)
|
|
185
|
+
backend.move_failures = {2: RuntimeError("metadata permission denied")}
|
|
186
|
+
statuses: list[str] = []
|
|
187
|
+
router = wp_router.WirePlumberStreamRouter("mini_eq_sink", "mini_eq_sink_output", statuses.append, backend)
|
|
188
|
+
router.set_output_sink_name("speakers")
|
|
189
|
+
|
|
190
|
+
with pytest.raises(RuntimeError, match="metadata permission denied"):
|
|
191
|
+
router.enable()
|
|
192
|
+
|
|
193
|
+
assert router.enabled is False
|
|
194
|
+
assert router.routed_stream_ids == set()
|
|
195
|
+
assert backend.moves == [(1, "mini_eq_sink"), (1, "speakers")]
|
|
196
|
+
assert backend.target_nodes[1] == "speakers"
|
|
197
|
+
assert statuses == ["routing warning: metadata permission denied"]
|
|
198
|
+
|
|
199
|
+
|
|
154
200
|
def test_wireplumber_router_always_writes_restore_move_for_tracked_streams() -> None:
|
|
155
201
|
backend = FakeWirePlumberBackend(
|
|
156
202
|
[make_node(1, wp_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify")],
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
APP_ID = "io.github.bhack.mini-eq"
|
|
16
|
+
DEFAULT_APP_REF = f"{APP_ID}//master"
|
|
17
|
+
SMOKE_APPLICATION_NAME = "mini-eq-flatpak-smoke"
|
|
18
|
+
VIRTUAL_SINK_NAME = "mini_eq_sink"
|
|
19
|
+
PIPEWIRE_MANAGER_ACCESS = "flatpak-manager"
|
|
20
|
+
TARGET_OBJECT_RE = re.compile(
|
|
21
|
+
r"update: id:(?P<id>\d+) key:'target\.object' value:'(?P<value>[^']*)' type:'(?P<type>[^']*)'"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def format_command(command: list[str | Path]) -> str:
|
|
26
|
+
return " ".join(str(part) for part in command)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run(command: list[str | Path], *, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
30
|
+
print(f"$ {format_command(command)}", flush=True)
|
|
31
|
+
return subprocess.run(
|
|
32
|
+
[str(part) for part in command],
|
|
33
|
+
check=check,
|
|
34
|
+
text=True,
|
|
35
|
+
stdout=subprocess.PIPE,
|
|
36
|
+
stderr=subprocess.STDOUT,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def require_tools(*tools: str) -> None:
|
|
41
|
+
missing = [tool for tool in tools if shutil.which(tool) is None]
|
|
42
|
+
if missing:
|
|
43
|
+
raise RuntimeError(f"Missing required tool(s): {', '.join(missing)}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def read_pw_dump() -> list[dict[str, Any]]:
|
|
47
|
+
result = subprocess.run(["pw-dump"], check=True, text=True, stdout=subprocess.PIPE)
|
|
48
|
+
payload = json.loads(result.stdout)
|
|
49
|
+
if not isinstance(payload, list):
|
|
50
|
+
raise RuntimeError("pw-dump returned an unexpected JSON shape")
|
|
51
|
+
return payload
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def item_props(item: dict[str, Any]) -> dict[str, Any]:
|
|
55
|
+
props = item.get("info", {}).get("props", {})
|
|
56
|
+
return props if isinstance(props, dict) else {}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def node_items() -> list[dict[str, Any]]:
|
|
60
|
+
return [item for item in read_pw_dump() if item.get("type") == "PipeWire:Interface:Node"]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def client_items() -> list[dict[str, Any]]:
|
|
64
|
+
return [item for item in read_pw_dump() if item.get("type") == "PipeWire:Interface:Client"]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def node_by_name(node_name: str) -> dict[str, Any] | None:
|
|
68
|
+
for node in node_items():
|
|
69
|
+
if item_props(node).get("node.name") == node_name:
|
|
70
|
+
return node
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def smoke_stream_node() -> dict[str, Any] | None:
|
|
75
|
+
for node in node_items():
|
|
76
|
+
props = item_props(node)
|
|
77
|
+
if (
|
|
78
|
+
props.get("media.class") == "Stream/Output/Audio"
|
|
79
|
+
and props.get("application.name") == SMOKE_APPLICATION_NAME
|
|
80
|
+
):
|
|
81
|
+
return node
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def object_serial(node: dict[str, Any]) -> str:
|
|
86
|
+
serial = item_props(node).get("object.serial")
|
|
87
|
+
if serial is None:
|
|
88
|
+
raise RuntimeError(f"PipeWire node has no object.serial: {item_props(node).get('node.name')}")
|
|
89
|
+
return str(serial)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def bound_id(node: dict[str, Any]) -> int:
|
|
93
|
+
node_id = node.get("id")
|
|
94
|
+
if not isinstance(node_id, int):
|
|
95
|
+
raise RuntimeError(f"PipeWire node has no integer id: {item_props(node).get('node.name')}")
|
|
96
|
+
return node_id
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def metadata_targets() -> dict[int, tuple[str, str]]:
|
|
100
|
+
result = subprocess.run(["pw-metadata", "-n", "default"], check=True, text=True, stdout=subprocess.PIPE)
|
|
101
|
+
targets: dict[int, tuple[str, str]] = {}
|
|
102
|
+
|
|
103
|
+
for line in result.stdout.splitlines():
|
|
104
|
+
match = TARGET_OBJECT_RE.search(line)
|
|
105
|
+
if match is not None:
|
|
106
|
+
targets[int(match.group("id"))] = (match.group("value"), match.group("type"))
|
|
107
|
+
|
|
108
|
+
return targets
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def wait_for(label: str, predicate: Callable[[], Any], timeout_seconds: float) -> Any:
|
|
112
|
+
deadline = time.monotonic() + timeout_seconds
|
|
113
|
+
last_error: Exception | None = None
|
|
114
|
+
|
|
115
|
+
while time.monotonic() < deadline:
|
|
116
|
+
try:
|
|
117
|
+
value = predicate()
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
last_error = exc
|
|
120
|
+
else:
|
|
121
|
+
if value:
|
|
122
|
+
return value
|
|
123
|
+
|
|
124
|
+
time.sleep(0.1)
|
|
125
|
+
|
|
126
|
+
detail = f": {last_error}" if last_error is not None else ""
|
|
127
|
+
raise RuntimeError(f"Timed out waiting for {label}{detail}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def mini_eq_has_manager_access() -> bool:
|
|
131
|
+
for client in client_items():
|
|
132
|
+
props = item_props(client)
|
|
133
|
+
if (
|
|
134
|
+
props.get("application.name") == "Mini EQ"
|
|
135
|
+
and props.get("media.category") == "Manager"
|
|
136
|
+
and props.get("pipewire.access.effective") == PIPEWIRE_MANAGER_ACCESS
|
|
137
|
+
):
|
|
138
|
+
return True
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def start_smoke_stream() -> subprocess.Popen[str]:
|
|
143
|
+
command = [
|
|
144
|
+
"pw-cat",
|
|
145
|
+
"--playback",
|
|
146
|
+
"--raw",
|
|
147
|
+
"--rate",
|
|
148
|
+
"48000",
|
|
149
|
+
"--channels",
|
|
150
|
+
"2",
|
|
151
|
+
"--format",
|
|
152
|
+
"s16",
|
|
153
|
+
"--volume",
|
|
154
|
+
"0",
|
|
155
|
+
"--properties",
|
|
156
|
+
f"application.name={SMOKE_APPLICATION_NAME}",
|
|
157
|
+
"/dev/zero",
|
|
158
|
+
]
|
|
159
|
+
print(f"$ {format_command(command)}", flush=True)
|
|
160
|
+
return subprocess.Popen(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def stop_process(process: subprocess.Popen[str], label: str, timeout_seconds: float = 5.0) -> str:
|
|
164
|
+
if process.poll() is not None:
|
|
165
|
+
return process.stdout.read() if process.stdout is not None else ""
|
|
166
|
+
|
|
167
|
+
process.terminate()
|
|
168
|
+
try:
|
|
169
|
+
output, _stderr = process.communicate(timeout=timeout_seconds)
|
|
170
|
+
except subprocess.TimeoutExpired:
|
|
171
|
+
process.kill()
|
|
172
|
+
output, _stderr = process.communicate(timeout=timeout_seconds)
|
|
173
|
+
|
|
174
|
+
if output:
|
|
175
|
+
print(f"{label} output:\n{output.rstrip()}", flush=True)
|
|
176
|
+
return output or ""
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def assert_no_existing_virtual_sink() -> None:
|
|
180
|
+
if node_by_name(VIRTUAL_SINK_NAME) is not None:
|
|
181
|
+
raise RuntimeError(
|
|
182
|
+
f"{VIRTUAL_SINK_NAME} already exists. Close Mini EQ and remove stale filter-chain state before rerunning."
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def run_runtime_smoke(app_ref: str, duration_seconds: float, timeout_seconds: float) -> None:
|
|
187
|
+
assert_no_existing_virtual_sink()
|
|
188
|
+
|
|
189
|
+
deps = run(["flatpak", "run", app_ref, "--check-deps"])
|
|
190
|
+
print(deps.stdout.rstrip(), flush=True)
|
|
191
|
+
|
|
192
|
+
smoke = start_smoke_stream()
|
|
193
|
+
app: subprocess.Popen[str] | None = None
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
smoke_node = wait_for("silent PipeWire smoke stream", smoke_stream_node, timeout_seconds)
|
|
197
|
+
smoke_id = bound_id(smoke_node)
|
|
198
|
+
original_target = metadata_targets().get(smoke_id)
|
|
199
|
+
|
|
200
|
+
command = [
|
|
201
|
+
"flatpak",
|
|
202
|
+
"run",
|
|
203
|
+
app_ref,
|
|
204
|
+
"--headless",
|
|
205
|
+
"--auto-route",
|
|
206
|
+
"--duration",
|
|
207
|
+
str(duration_seconds),
|
|
208
|
+
]
|
|
209
|
+
print(f"$ {format_command(command)}", flush=True)
|
|
210
|
+
app = subprocess.Popen(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
|
211
|
+
|
|
212
|
+
wait_for("Mini EQ PipeWire manager access", mini_eq_has_manager_access, timeout_seconds)
|
|
213
|
+
virtual_sink = wait_for(
|
|
214
|
+
f"{VIRTUAL_SINK_NAME} PipeWire node", lambda: node_by_name(VIRTUAL_SINK_NAME), timeout_seconds
|
|
215
|
+
)
|
|
216
|
+
virtual_serial = object_serial(virtual_sink)
|
|
217
|
+
|
|
218
|
+
def smoke_stream_targets_virtual_sink() -> bool:
|
|
219
|
+
target = metadata_targets().get(smoke_id)
|
|
220
|
+
return target == (virtual_serial, "Spa:Id")
|
|
221
|
+
|
|
222
|
+
wait_for("smoke stream routed through Mini EQ", smoke_stream_targets_virtual_sink, timeout_seconds)
|
|
223
|
+
|
|
224
|
+
output, _stderr = app.communicate(timeout=max(duration_seconds + timeout_seconds, timeout_seconds))
|
|
225
|
+
print(output.rstrip(), flush=True)
|
|
226
|
+
if app.returncode != 0:
|
|
227
|
+
raise RuntimeError(f"Mini EQ Flatpak exited with status {app.returncode}")
|
|
228
|
+
|
|
229
|
+
def smoke_stream_restored() -> bool:
|
|
230
|
+
restored_target = metadata_targets().get(smoke_id)
|
|
231
|
+
if original_target is not None:
|
|
232
|
+
return restored_target == original_target
|
|
233
|
+
return restored_target != (virtual_serial, "Spa:Id")
|
|
234
|
+
|
|
235
|
+
wait_for("smoke stream restored after Mini EQ exits", smoke_stream_restored, timeout_seconds)
|
|
236
|
+
|
|
237
|
+
if original_target is not None:
|
|
238
|
+
restored_target = metadata_targets().get(smoke_id)
|
|
239
|
+
print(f"Smoke stream restored to {restored_target}.", flush=True)
|
|
240
|
+
|
|
241
|
+
print("Flatpak runtime smoke passed: manager access, stream routing, and restore behavior verified.")
|
|
242
|
+
finally:
|
|
243
|
+
if app is not None and app.poll() is None:
|
|
244
|
+
stop_process(app, "Mini EQ Flatpak")
|
|
245
|
+
stop_process(smoke, "pw-cat smoke stream")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
249
|
+
parser = argparse.ArgumentParser(
|
|
250
|
+
description="Smoke-test Mini EQ Flatpak runtime routing against the live PipeWire session.",
|
|
251
|
+
)
|
|
252
|
+
parser.add_argument(
|
|
253
|
+
"--app-ref",
|
|
254
|
+
default=DEFAULT_APP_REF,
|
|
255
|
+
help=f"Flatpak app ref to test (default: {DEFAULT_APP_REF})",
|
|
256
|
+
)
|
|
257
|
+
parser.add_argument(
|
|
258
|
+
"--duration",
|
|
259
|
+
type=float,
|
|
260
|
+
default=8.0,
|
|
261
|
+
help="How long to keep the headless Mini EQ app running during the routing check.",
|
|
262
|
+
)
|
|
263
|
+
parser.add_argument(
|
|
264
|
+
"--timeout",
|
|
265
|
+
type=float,
|
|
266
|
+
default=8.0,
|
|
267
|
+
help="Timeout in seconds for each PipeWire state transition.",
|
|
268
|
+
)
|
|
269
|
+
return parser.parse_args(argv)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def main(argv: list[str] | None = None) -> int:
|
|
273
|
+
args = parse_args(sys.argv[1:] if argv is None else argv)
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
require_tools("flatpak", "pw-cat", "pw-dump", "pw-metadata")
|
|
277
|
+
run_runtime_smoke(args.app_ref, args.duration, args.timeout)
|
|
278
|
+
except subprocess.CalledProcessError as exc:
|
|
279
|
+
if exc.stdout:
|
|
280
|
+
sys.stderr.write(exc.stdout)
|
|
281
|
+
return exc.returncode
|
|
282
|
+
except Exception as exc:
|
|
283
|
+
print(exc, file=sys.stderr)
|
|
284
|
+
return 1
|
|
285
|
+
|
|
286
|
+
return 0
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
if __name__ == "__main__":
|
|
290
|
+
raise SystemExit(main())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mini_eq-0.3.1 → mini_eq-0.3.2}/extensions/gnome-shell/mini-eq@bhack.github.io/metadata.json
RENAMED
|
File without changes
|
{mini_eq-0.3.1 → mini_eq-0.3.2}/extensions/gnome-shell/mini-eq@bhack.github.io/mini-eq-symbolic.svg
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|