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.
Files changed (99) hide show
  1. {mini_eq-0.3.1 → mini_eq-0.3.2}/CHANGELOG.md +7 -0
  2. {mini_eq-0.3.1/src/mini_eq.egg-info → mini_eq-0.3.2}/PKG-INFO +1 -1
  3. {mini_eq-0.3.1 → mini_eq-0.3.2}/data/io.github.bhack.mini-eq.metainfo.xml +11 -2
  4. {mini_eq-0.3.1 → mini_eq-0.3.2}/docs/flathub.md +8 -0
  5. {mini_eq-0.3.1 → mini_eq-0.3.2}/docs/release.md +28 -0
  6. {mini_eq-0.3.1 → mini_eq-0.3.2}/pyproject.toml +1 -1
  7. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/window.py +6 -1
  8. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/wireplumber_backend.py +16 -2
  9. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/wireplumber_stream_router.py +12 -4
  10. {mini_eq-0.3.1 → mini_eq-0.3.2/src/mini_eq.egg-info}/PKG-INFO +1 -1
  11. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq.egg-info/SOURCES.txt +1 -0
  12. {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_window.py +33 -2
  13. {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_wireplumber_backend.py +130 -0
  14. {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_wireplumber_stream_router.py +46 -0
  15. mini_eq-0.3.2/tools/check_flatpak_runtime.py +290 -0
  16. {mini_eq-0.3.1 → mini_eq-0.3.2}/tools/check_wireplumber_gi.py +1 -0
  17. {mini_eq-0.3.1 → mini_eq-0.3.2}/AGENTS.md +0 -0
  18. {mini_eq-0.3.1 → mini_eq-0.3.2}/LICENSE +0 -0
  19. {mini_eq-0.3.1 → mini_eq-0.3.2}/MANIFEST.in +0 -0
  20. {mini_eq-0.3.1 → mini_eq-0.3.2}/README.md +0 -0
  21. {mini_eq-0.3.1 → mini_eq-0.3.2}/SECURITY.md +0 -0
  22. {mini_eq-0.3.1 → mini_eq-0.3.2}/data/io.github.bhack.mini-eq.desktop +0 -0
  23. {mini_eq-0.3.1 → mini_eq-0.3.2}/docker/ubuntu-24.04-wp04.Dockerfile +0 -0
  24. {mini_eq-0.3.1 → mini_eq-0.3.2}/docs/performance.md +0 -0
  25. {mini_eq-0.3.1 → mini_eq-0.3.2}/docs/screenshots/README.md +0 -0
  26. {mini_eq-0.3.1 → mini_eq-0.3.2}/docs/screenshots/mini-eq-dark.png +0 -0
  27. {mini_eq-0.3.1 → mini_eq-0.3.2}/docs/screenshots/mini-eq.png +0 -0
  28. {mini_eq-0.3.1 → mini_eq-0.3.2}/docs/social-preview.png +0 -0
  29. {mini_eq-0.3.1 → mini_eq-0.3.2}/extensions/gnome-shell/README.md +0 -0
  30. {mini_eq-0.3.1 → mini_eq-0.3.2}/extensions/gnome-shell/mini-eq@bhack.github.io/extension.js +0 -0
  31. {mini_eq-0.3.1 → mini_eq-0.3.2}/extensions/gnome-shell/mini-eq@bhack.github.io/metadata.json +0 -0
  32. {mini_eq-0.3.1 → mini_eq-0.3.2}/extensions/gnome-shell/mini-eq@bhack.github.io/mini-eq-symbolic.svg +0 -0
  33. {mini_eq-0.3.1 → mini_eq-0.3.2}/flatpak/patches/wireplumber-0.5.14-tools-disabled-po.patch +0 -0
  34. {mini_eq-0.3.1 → mini_eq-0.3.2}/io.github.bhack.mini-eq.yaml +0 -0
  35. {mini_eq-0.3.1 → mini_eq-0.3.2}/python3-dependencies.yaml +0 -0
  36. {mini_eq-0.3.1 → mini_eq-0.3.2}/setup.cfg +0 -0
  37. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/__init__.py +0 -0
  38. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/__main__.py +0 -0
  39. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/analyzer.py +0 -0
  40. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/analyzer_widget.py +0 -0
  41. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/app.py +0 -0
  42. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/appearance.py +0 -0
  43. {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
  44. {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
  45. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/band_fader.py +0 -0
  46. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/cli.py +0 -0
  47. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/core.py +0 -0
  48. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/dbus_control.py +0 -0
  49. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/deps.py +0 -0
  50. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/desktop_integration.py +0 -0
  51. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/ebur128.py +0 -0
  52. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/filter_chain.py +0 -0
  53. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/glib_utils.py +0 -0
  54. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/gtk_utils.py +0 -0
  55. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/instance.py +0 -0
  56. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/routing.py +0 -0
  57. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/screenshot.py +0 -0
  58. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/style.css +0 -0
  59. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/window_analyzer.py +0 -0
  60. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/window_graph.py +0 -0
  61. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/window_headroom.py +0 -0
  62. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/window_layout.py +0 -0
  63. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/window_presets.py +0 -0
  64. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/window_utility.py +0 -0
  65. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq/window_utils.py +0 -0
  66. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq.egg-info/dependency_links.txt +0 -0
  67. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq.egg-info/entry_points.txt +0 -0
  68. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq.egg-info/requires.txt +0 -0
  69. {mini_eq-0.3.1 → mini_eq-0.3.2}/src/mini_eq.egg-info/top_level.txt +0 -0
  70. {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_gnome_shell_extension.py +0 -0
  71. {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_analyzer.py +0 -0
  72. {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_app.py +0 -0
  73. {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_appearance.py +0 -0
  74. {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_cli.py +0 -0
  75. {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_core.py +0 -0
  76. {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_dbus_control.py +0 -0
  77. {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_deps.py +0 -0
  78. {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_desktop_integration.py +0 -0
  79. {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_ebur128.py +0 -0
  80. {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_filter_chain.py +0 -0
  81. {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_instance.py +0 -0
  82. {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_output_presets.py +0 -0
  83. {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_routing.py +0 -0
  84. {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_window_analyzer.py +0 -0
  85. {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_mini_eq_window_graph.py +0 -0
  86. {mini_eq-0.3.1 → mini_eq-0.3.2}/tests/test_version_metadata.py +0 -0
  87. {mini_eq-0.3.1 → mini_eq-0.3.2}/tools/benchmark_fader_drag.py +0 -0
  88. {mini_eq-0.3.1 → mini_eq-0.3.2}/tools/check_flathub_manifest_drift.py +0 -0
  89. {mini_eq-0.3.1 → mini_eq-0.3.2}/tools/check_gnome_shell_extension.py +0 -0
  90. {mini_eq-0.3.1 → mini_eq-0.3.2}/tools/demo_runtime.py +0 -0
  91. {mini_eq-0.3.1 → mini_eq-0.3.2}/tools/gnome-shell-extension/fake_mini_eq_control.py +0 -0
  92. {mini_eq-0.3.1 → mini_eq-0.3.2}/tools/pack_gnome_shell_extension.sh +0 -0
  93. {mini_eq-0.3.1 → mini_eq-0.3.2}/tools/profile_live_app.py +0 -0
  94. {mini_eq-0.3.1 → mini_eq-0.3.2}/tools/profile_sysprof.py +0 -0
  95. {mini_eq-0.3.1 → mini_eq-0.3.2}/tools/release_post_publish.py +0 -0
  96. {mini_eq-0.3.1 → mini_eq-0.3.2}/tools/release_preflight.py +0 -0
  97. {mini_eq-0.3.1 → mini_eq-0.3.2}/tools/render_demo_screenshot.py +0 -0
  98. {mini_eq-0.3.1 → mini_eq-0.3.2}/tools/render_social_preview.py +0 -0
  99. {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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mini-eq
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: Compact PipeWire system-wide parametric equalizer for Linux desktops.
5
5
  Author: bhack
6
6
  License-Expression: GPL-3.0-or-later
@@ -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.1/docs/screenshots/mini-eq.png</image>
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.1/docs/screenshots/mini-eq-dark.png</image>
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mini-eq"
7
- version = "0.3.1"
7
+ version = "0.3.2"
8
8
  description = "Compact PipeWire system-wide parametric equalizer for Linux desktops."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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, 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
- self.start_monitoring()
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mini-eq
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: Compact PipeWire system-wide parametric equalizer for Linux desktops.
5
5
  Author: bhack
6
6
  License-Expression: GPL-3.0-or-later
@@ -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(("route", enabled, announce))
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())
@@ -11,6 +11,7 @@ REQUIRED_WP_SYMBOLS = (
11
11
  "Core.new",
12
12
  "ObjectManager.new",
13
13
  "ObjectInterest.new_type",
14
+ "Properties.new_empty",
14
15
  "Node",
15
16
  "Metadata",
16
17
  "ProxyFeatures.PIPEWIRE_OBJECT_FEATURE_INFO",
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