diff --git a/docs/UPDATE.md b/docs/UPDATE.md
index d76ccb7..0109824 100644
--- a/docs/UPDATE.md
+++ b/docs/UPDATE.md
@@ -324,7 +324,7 @@ See the [full changelog](https://github.com/VocaHQ/vocalinux/releases/tag/v0.18.
- Distro matrix runs `install.sh` (#810)
- `install.sh` Faster Whisper box is CPU CTranslate2 / INT8, not NVIDIA CUDA. The whisper.cpp GPU step does not install the CUDA toolkit; Vulkan is first, CUDA only if a toolkit is already on the machine (#826)
-AppImage and Flatpak remain whisper.cpp only. Snap ships whisper.cpp plus VOSK. Faster Whisper and Parakeet are optional extras on `install.sh` / source installs.
+AppImage, Flatpak and Snap remain whisper.cpp only. Faster Whisper and Parakeet are optional extras on `install.sh` / source installs.
See the [full changelog](https://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0).
diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py
index a97f32b..37c6d27 100644
--- a/src/vocalinux/ui/settings_dialog.py
+++ b/src/vocalinux/ui/settings_dialog.py
@@ -9977,7 +9977,10 @@ For now, the engine has been reverted to VOSK."""
self.speech_engine.set_audio_device(device_index, device_name)
logger.info(f"Audio device changed to: [{device_index}] {device_name}")
- self.audio_test_status.set_markup(f"Selected: {device_label}")
+ # Device names are free text ("Mic & Line", ""); escape them for Pango.
+ self.audio_test_status.set_markup(
+ f"Selected: {GLib.markup_escape_text(device_label or '')}"
+ )
def _cancel_audio_test(self, *_args: object) -> None:
"""Cancel capture and invalidate any queued result when Settings closes."""
@@ -10048,7 +10051,7 @@ For now, the engine has been reverted to VOSK."""
"Check if microphone is muted or try a different device"
)
else:
- error_msg = result.get("error", "Unknown error")
+ error_msg = GLib.markup_escape_text(str(result.get("error", "Unknown error")))
self.audio_test_status.set_markup(
f"✗ Test failed: {error_msg}"
)
diff --git a/tests/test_pulse_sources.py b/tests/test_pulse_sources.py
index 055e37b..98197a0 100644
--- a/tests/test_pulse_sources.py
+++ b/tests/test_pulse_sources.py
@@ -679,6 +679,38 @@ class TestAudioDeviceTooltip:
dialog.audio_device_combo.set_tooltip_text.assert_called_with(self._SOF)
+class TestAudioStatusMarkup:
+ """Device names and errors are free text; the status label parses Pango markup."""
+
+ def _glib(self) -> MagicMock:
+ from xml.sax.saxutils import escape
+
+ return MagicMock(markup_escape_text=MagicMock(side_effect=escape))
+
+ def test_selected_device_name_is_escaped(self) -> None:
+ settings_dialog = _load_settings_dialog()
+ dialog = MagicMock(_initializing=False, _pulse_source_names={})
+ dialog.audio_device_combo.get_active_id.return_value = "2"
+ dialog.audio_device_combo.get_active_text.return_value = "Mic & Line "
+ with patch.object(settings_dialog, "GLib", self._glib()):
+ settings_dialog.SettingsDialog._on_audio_device_changed(dialog, None)
+ dialog.audio_test_status.set_markup.assert_called_with(
+ "Selected: Mic & Line <A2DP>"
+ )
+
+ def test_test_failure_message_is_escaped(self) -> None:
+ settings_dialog = _load_settings_dialog()
+ dialog = MagicMock()
+ with patch.object(settings_dialog, "GLib", self._glib()):
+ settings_dialog.SettingsDialog._handle_audio_test_result(
+ dialog, {"success": False, "error": "Invalid device & busy"}
+ )
+ dialog.audio_test_status.set_markup.assert_called_with(
+ "✗ Test failed: "
+ "Invalid device <hw:0,6> & busy"
+ )
+
+
def _load_settings_dialog() -> ModuleType:
"""Import settings_dialog with real GTK base classes so its methods are callable.