fix(tray): use icon names for AppIndicator updates (#330)

* fix(tray): use icon names for AppIndicator updates

AppIndicator expects icon names resolved via theme path; passing absolute paths can result in blank or stuck status icons on some desktops. Use icon names for runtime updates and tighten tests to lock behavior.

* fix(resources): prefer populated asset directories

Selecting the first existing resources path could choose an empty install location and break tray icons and sounds. Score candidates by expected assets so runtime picks a usable resources directory.
This commit is contained in:
Jatin K Malik
2026-03-21 12:18:21 -07:00
committed by GitHub
parent fa17b88f74
commit 9e0a98c0a3
4 changed files with 115 additions and 18 deletions
+10 -4
View File
@@ -95,6 +95,11 @@ class TrayIndicator:
"active": _resource_manager.get_icon_path(ACTIVE_ICON),
"processing": _resource_manager.get_icon_path(PROCESSING_ICON),
}
self.icon_names = {
"default": DEFAULT_ICON,
"active": ACTIVE_ICON,
"processing": PROCESSING_ICON,
}
# Register for speech recognition state changes
self.speech_engine.register_state_callback(self._on_recognition_state_changed)
@@ -186,6 +191,7 @@ class TrayIndicator:
AppIndicator3.IndicatorCategory.APPLICATION_STATUS,
ICON_DIR,
)
self.indicator.set_icon_theme_path(ICON_DIR)
# Set the indicator status
self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE)
@@ -324,19 +330,19 @@ class TrayIndicator:
state: The current recognition state
"""
if state == RecognitionState.IDLE:
self.indicator.set_icon_full(self.icon_paths["default"], "Microphone off")
self.indicator.set_icon_full(self.icon_names["default"], "Microphone off")
self._set_menu_item_enabled("Start Voice Typing", True)
self._set_menu_item_enabled("Stop Voice Typing", False)
elif state == RecognitionState.LISTENING:
self.indicator.set_icon_full(self.icon_paths["active"], "Microphone on")
self.indicator.set_icon_full(self.icon_names["active"], "Microphone on")
self._set_menu_item_enabled("Start Voice Typing", False)
self._set_menu_item_enabled("Stop Voice Typing", True)
elif state == RecognitionState.PROCESSING:
self.indicator.set_icon_full(self.icon_paths["processing"], "Processing speech")
self.indicator.set_icon_full(self.icon_names["processing"], "Processing speech")
self._set_menu_item_enabled("Start Voice Typing", False)
self._set_menu_item_enabled("Stop Voice Typing", True)
elif state == RecognitionState.ERROR:
self.indicator.set_icon_full(self.icon_paths["default"], "Error")
self.indicator.set_icon_full(self.icon_names["default"], "Error")
self._set_menu_item_enabled("Start Voice Typing", True)
self._set_menu_item_enabled("Stop Voice Typing", False)
+52 -11
View File
@@ -9,7 +9,7 @@ import logging
import os
import sys
from pathlib import Path
from typing import Optional # noqa: F401
from typing import Optional, cast
logger = logging.getLogger(__name__)
@@ -24,7 +24,7 @@ class ResourceManager:
"""
_instance = None
_resources_dir = None
_resources_dir: Optional[str] = None
def __new__(cls):
"""Singleton pattern to ensure only one instance exists."""
@@ -86,31 +86,71 @@ class ResourceManager:
f"Checking resources candidate: {candidate} (exists: {candidate.exists()})"
)
# Return the first candidate that exists
for candidate in candidates:
if candidate.exists():
logger.info(f"Found resources directory: {candidate}")
return str(candidate)
best_candidate = self._choose_best_candidate(candidates)
if best_candidate is not None:
logger.info(f"Found resources directory: {best_candidate}")
return str(best_candidate)
# If no candidate exists, default to the first one (with warning)
default_path = str(candidates[0])
logger.warning(f"Could not find resources directory, defaulting to: {default_path}")
return default_path
def _candidate_score(self, candidate: Path) -> int:
if not candidate.exists():
return -1
expected_icons = [
"vocalinux",
"vocalinux-microphone",
"vocalinux-microphone-off",
"vocalinux-microphone-process",
]
expected_sounds = ["start_recording", "stop_recording", "error"]
icon_score = sum(
(candidate / "icons" / "scalable" / f"{icon}.svg").exists() for icon in expected_icons
)
sound_score = sum(
(candidate / "sounds" / f"{sound}.wav").exists() for sound in expected_sounds
)
total_score = icon_score + sound_score
logger.debug(
"Resource candidate score: %s (icons=%s sounds=%s total=%s)",
candidate,
icon_score,
sound_score,
total_score,
)
return total_score
def _choose_best_candidate(self, candidates: list[Path]) -> Optional[Path]:
best_candidate: Optional[Path] = None
best_score = -1
for candidate in candidates:
score = self._candidate_score(candidate)
if score > best_score:
best_score = score
best_candidate = candidate
return best_candidate
@property
def resources_dir(self) -> str:
"""Get the resources directory path."""
return self._resources_dir
return cast(str, self._resources_dir)
@property
def icons_dir(self) -> str:
"""Get the icons directory path."""
return os.path.join(self._resources_dir, "icons", "scalable")
return os.path.join(self.resources_dir, "icons", "scalable")
@property
def sounds_dir(self) -> str:
"""Get the sounds directory path."""
return os.path.join(self._resources_dir, "sounds")
return os.path.join(self.resources_dir, "sounds")
def get_icon_path(self, icon_name: str) -> str:
"""
@@ -148,8 +188,9 @@ class ResourceManager:
Returns:
Dictionary with validation results
"""
resources_dir = self.resources_dir
results = {
"resources_dir_exists": os.path.exists(self._resources_dir),
"resources_dir_exists": os.path.exists(resources_dir),
"icons_dir_exists": os.path.exists(self.icons_dir),
"sounds_dir_exists": os.path.exists(self.sounds_dir),
"missing_icons": [],
+44
View File
@@ -143,3 +143,47 @@ class TestResourceManagerLogging:
result = manager._find_resources_dir()
assert isinstance(result, str)
assert len(result) > 0
class TestResourceManagerCandidateSelection:
def test_choose_best_candidate_prefers_assets_over_empty_existing_dir(self):
from vocalinux.utils.resource_manager import ResourceManager
ResourceManager._instance = None
ResourceManager._resources_dir = None
manager = ResourceManager()
with tempfile.TemporaryDirectory() as tmp_dir:
empty_candidate = Path(tmp_dir) / "empty_resources"
full_candidate = Path(tmp_dir) / "full_resources"
empty_candidate.mkdir(parents=True, exist_ok=True)
(full_candidate / "icons" / "scalable").mkdir(parents=True, exist_ok=True)
(full_candidate / "sounds").mkdir(parents=True, exist_ok=True)
for icon in [
"vocalinux",
"vocalinux-microphone",
"vocalinux-microphone-off",
"vocalinux-microphone-process",
]:
(full_candidate / "icons" / "scalable" / f"{icon}.svg").write_text("x")
for sound in ["start_recording", "stop_recording", "error"]:
(full_candidate / "sounds" / f"{sound}.wav").write_text("x")
choose_best_candidate = getattr(manager, "_choose_best_candidate")
chosen = choose_best_candidate([empty_candidate, full_candidate])
assert chosen == full_candidate
def test_candidate_score_returns_negative_for_missing_path(self):
from vocalinux.utils.resource_manager import ResourceManager
ResourceManager._instance = None
ResourceManager._resources_dir = None
manager = ResourceManager()
missing = Path("/tmp/this-path-should-not-exist-vocalinux-resource-manager")
candidate_score = getattr(manager, "_candidate_score")
score = candidate_score(missing)
assert score == -1
+9 -3
View File
@@ -322,7 +322,9 @@ class TestTrayIndicator(unittest.TestCase):
patched_gtk.MenuItem = type(mock_menu_item)
result = self.tray_indicator._update_ui(self.RecognitionState.LISTENING)
self.tray_indicator.indicator.set_icon_full.assert_called_once()
self.tray_indicator.indicator.set_icon_full.assert_called_once_with(
"vocalinux-microphone", "Microphone on"
)
self.assertEqual(result, False)
def test_update_ui_processing_state(self):
@@ -337,7 +339,9 @@ class TestTrayIndicator(unittest.TestCase):
patched_gtk.MenuItem = type(mock_menu_item)
result = self.tray_indicator._update_ui(self.RecognitionState.PROCESSING)
self.tray_indicator.indicator.set_icon_full.assert_called_once()
self.tray_indicator.indicator.set_icon_full.assert_called_once_with(
"vocalinux-microphone-process", "Processing speech"
)
self.assertEqual(result, False)
def test_update_ui_error_state(self):
@@ -352,7 +356,9 @@ class TestTrayIndicator(unittest.TestCase):
patched_gtk.MenuItem = type(mock_menu_item)
result = self.tray_indicator._update_ui(self.RecognitionState.ERROR)
self.tray_indicator.indicator.set_icon_full.assert_called_once()
self.tray_indicator.indicator.set_icon_full.assert_called_once_with(
"vocalinux-microphone-off", "Error"
)
self.assertEqual(result, False)
def test_set_menu_item_enabled(self):