feat(ui): add auto-start on boot support (#207)

* feat(ui): add auto-start on boot support

- Add --start-minimized CLI flag for autostart entry
- Create autostart_manager.py to handle XDG autostart desktop entry
- Add autostart config option in config_manager.py
- Add Start on Login toggle in Settings dialog
- Add Start Minimized toggle in Settings dialog
- Add Start on Login checkbox in system tray menu
- Add first-run dialog to ask about autostart preference

Closes #184

* fix(test): add first_run: False to mock config settings

The tests were failing with StopIteration because main() now checks
for first_run and shows a dialog if True. By setting first_run: False
in the mock config, we skip the first-run dialog code path.

* fix(ui): harden autostart behavior across Linux sessions

Use XDG-compliant autostart paths and robust Exec command resolution, keep first-run prompt semantics correct, and fix GTK toggle handling so state changes are applied reliably.

* docs(autostart): document XDG startup model and limits

Clarify that Vocalinux uses desktop-session XDG autostart, not systemd services, and explain compatibility expectations for mainstream DEs versus minimal/custom WM sessions.

* feat(ui): add first-run quick-start guidance text

Expand the welcome dialog with concise first-time usage guidance and clear direction to the tray menu Settings entry while preserving the existing autostart decision flow.

* fix(uninstall): remove XDG autostart desktop entry

Ensure uninstall removes ~/.config autostart registration and reports leftover autostart entries during verification.

* fix(install): ignore defunct processes in running-check

Filter installer process detection to exclude defunct entries and installer/uninstaller shells so stale zombie rows do not block reinstall.

* fix(uninstall): ignore defunct processes in running-check

Use filtered process detection so zombie entries and installer shells do not trigger false running-process warnings during uninstall.
This commit is contained in:
Jatin K Malik
2026-02-21 15:06:20 -08:00
committed by GitHub
parent f3bcffad60
commit 9886e8cb9d
14 changed files with 720 additions and 16 deletions
+24
View File
@@ -76,6 +76,7 @@
- 🤖 **whisper.cpp by default** - High-performance C++ speech recognition
- 🎮 **Universal GPU support** - Vulkan acceleration for AMD, Intel, and NVIDIA
- 🎨 **System tray integration** with visual status indicators
- 🚀 **Start on login support** via XDG autostart (desktop-session startup)
- 🔊 **Pleasant audio feedback** - smooth gliding tones, headphone-friendly
- ⚙️ **Graphical settings** dialog for easy configuration
- 📦 **3 engine choices** - whisper.cpp (default), OpenAI Whisper, or VOSK
@@ -239,8 +240,30 @@ vocalinux --engine whisper # Use OpenAI Whisper engine
vocalinux --engine vosk # Use VOSK engine
vocalinux --model medium # Use medium-sized model
vocalinux --wayland # Force Wayland mode
vocalinux --start-minimized # Start without first-run modal prompts
```
### Autostart on Login
Vocalinux uses the Linux desktop standard for autostart:
- **Mechanism**: XDG autostart desktop entry (`vocalinux.desktop`)
- **Path**: `$XDG_CONFIG_HOME/autostart/` or `~/.config/autostart/` (fallback)
- **Launch mode**: Starts as a regular **user desktop app** in your graphical session
- **Not used**: No `systemd` unit/service is created by Vocalinux for autostart
How to enable/disable:
- First-run welcome dialog
- Tray menu: **Start on Login**
- Settings dialog: **Start on Login**
Compatibility notes:
- Works on mainstream desktop environments (GNOME, KDE, Xfce, Cinnamon, MATE, LXQt)
- On minimal/custom window-manager sessions, an autostart handler may be required
(for example DE-specific startup hooks or tools like `dex`)
## ⚙️ Configuration
Configuration is stored in `~/.config/vocalinux/config.json`:
@@ -301,6 +324,7 @@ vocalinux/
- [Installation Guide](docs/INSTALL.md) - Detailed installation instructions
- [Update Guide](docs/UPDATE.md) - How to update Vocalinux
- [User Guide](docs/USER_GUIDE.md) - Complete user documentation
- [Distribution Compatibility](docs/DISTRO_COMPATIBILITY.md) - Distro/session behavior and caveats
- [Contributing](CONTRIBUTING.md) - Development setup and contribution guidelines
## 🔊 Sound Customization
+15
View File
@@ -204,6 +204,21 @@ For unsupported or experimental distributions:
| i3 | ✅ Good Support | X11, uses xdotool |
| Hyprland | ⚠️ Experimental | Wayland, may need wtype configuration |
### Autostart Compatibility
Vocalinux uses **XDG autostart desktop entries** for "Start on Login".
- Desktop-session autostart file location:
- `$XDG_CONFIG_HOME/autostart/vocalinux.desktop`, or
- `~/.config/autostart/vocalinux.desktop` (fallback)
- This launches Vocalinux as a user GUI app after login.
- Vocalinux does **not** create a `systemd` service/unit for this feature.
Practical caveat:
- Mainstream desktop environments generally honor XDG autostart.
- Minimal/custom WM sessions may need an explicit autostart manager or WM startup hook.
## Display Server
| Display Server | Status | Notes |
+13
View File
@@ -166,8 +166,21 @@ vocalinux --model small # Use small model
vocalinux --model medium # Use medium model
vocalinux --model large # Use large model
vocalinux --wayland # Force Wayland compatibility mode
vocalinux --start-minimized # Start without first-run modal prompts
```
## Autostart Approach
Vocalinux autostart is implemented with **XDG Autostart** (desktop entry), not as a `systemd` background service.
- Enabling **Start on Login** creates `vocalinux.desktop` in:
- `$XDG_CONFIG_HOME/autostart/`, or
- `~/.config/autostart/` (fallback)
- The autostart entry launches Vocalinux as a normal user desktop app at login.
- No `systemd --user` unit is created by Vocalinux for this feature.
This keeps behavior aligned with standard Linux desktop sessions and avoids service/session environment mismatches for GUI apps.
## Directory Structure
Vocalinux follows the [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html):
+21 -1
View File
@@ -4,7 +4,27 @@ This guide explains how to use Vocalinux effectively.
## Getting Started
After installing Vocalinux (see the [Installation Guide](INSTALL.md)), you can start the application from the terminal or add it to your startup applications.
After installing Vocalinux (see the [Installation Guide](INSTALL.md)), you can start the application from the terminal and optionally enable start-on-login.
## Start on Login (Autostart)
Vocalinux supports login autostart using the standard Linux desktop-session mechanism.
- **Where to enable it**:
- First-run welcome dialog
- Tray menu: **Start on Login**
- Settings dialog: **Start on Login**
- **What Vocalinux creates**:
- `vocalinux.desktop` in `$XDG_CONFIG_HOME/autostart/` or `~/.config/autostart/`
- **How it starts**:
- As a regular user GUI app in your desktop session (`--start-minimized`)
- **What it does not do**:
- It does not create a `systemd` service/unit for autostart
### Desktop Compatibility Notes
- Works on most mainstream desktop environments (GNOME, KDE, Xfce, Cinnamon, MATE, LXQt)
- On minimal/custom window managers, autostart may require an autostart manager or desktop-specific startup hook
## Basic Usage
+30 -3
View File
@@ -25,8 +25,33 @@ command_exists() {
command -v "$1" >/dev/null 2>&1
}
get_vocalinux_pids() {
pgrep -f "vocalinux" 2>/dev/null | while read -r pid; do
[ -z "$pid" ] && continue
if [ "$pid" = "$$" ] || [ "$pid" = "$PPID" ]; then
continue
fi
local stat
stat=$(ps -o stat= -p "$pid" 2>/dev/null | awk '{print $1}')
if [[ "$stat" == Z* ]]; then
continue
fi
local cmd
cmd=$(ps -o args= -p "$pid" 2>/dev/null)
if [[ "$cmd" == *"install.sh"* ]] || [[ "$cmd" == *"uninstall.sh"* ]]; then
continue
fi
echo "$pid"
done
}
check_running_processes() {
local PIDS=$(pgrep -f "vocalinux" 2>/dev/null || true)
local PIDS
PIDS=$(get_vocalinux_pids || true)
if [ -n "$PIDS" ]; then
print_warning "Found running Vocalinux process(es): $PIDS"
@@ -48,7 +73,8 @@ check_running_processes() {
echo "$PIDS" | xargs -r kill -TERM 2>/dev/null || true
sleep 2
local REMAINING_PIDS=$(pgrep -f "vocalinux" 2>/dev/null || true)
local REMAINING_PIDS
REMAINING_PIDS=$(get_vocalinux_pids || true)
if [ -n "$REMAINING_PIDS" ]; then
print_warning "Some processes still running, forcing termination..."
@@ -56,7 +82,8 @@ check_running_processes() {
sleep 1
fi
local FINAL_PIDS=$(pgrep -f "vocalinux" 2>/dev/null || true)
local FINAL_PIDS
FINAL_PIDS=$(get_vocalinux_pids || true)
if [ -n "$FINAL_PIDS" ]; then
print_error "Could not terminate all Vocalinux processes: $FINAL_PIDS"
print_error "Please manually kill these processes and run the installer again."
+30
View File
@@ -61,6 +61,11 @@ def parse_arguments():
help="Speech recognition engine to use (whisper_cpp recommended for best performance)",
)
parser.add_argument("--wayland", action="store_true", help="Force Wayland compatibility mode")
parser.add_argument(
"--start-minimized",
action="store_true",
help="Start minimized to system tray",
)
return parser.parse_args()
@@ -159,6 +164,31 @@ def main():
saved_settings = config_manager.get_settings().get("speech_recognition", {})
audio_settings = config_manager.get_settings().get("audio", {})
general_settings = config_manager.get_settings().get("general", {})
first_run = general_settings.get("first_run", True)
should_prompt_first_run = first_run and not args.start_minimized
if should_prompt_first_run:
from .ui.first_run_dialog import show_first_run_dialog
result = show_first_run_dialog()
if result == "yes":
from .ui import autostart_manager
if autostart_manager.set_autostart(True):
config_manager.set("general", "autostart", True)
else:
config_manager.set("general", "autostart", False)
elif result == "no":
from .ui import autostart_manager
autostart_manager.set_autostart(False)
config_manager.set("general", "autostart", False)
if result in {"yes", "no"}:
config_manager.set("general", "first_run", False)
config_manager.save_settings()
# CLI arguments take precedence over saved config
# We need to check if the user explicitly provided arguments
# by examining sys.argv since argparse defaults don't tell us this
+98
View File
@@ -0,0 +1,98 @@
"""
Autostart manager for Vocalinux.
This module handles the creation and removal of the XDG autostart desktop entry
for automatically starting Vocalinux on login.
"""
import logging
import os
import shlex
import shutil
import sys
from pathlib import Path
logger = logging.getLogger(__name__)
DESKTOP_FILENAME = "vocalinux.desktop"
def get_autostart_dir() -> Path:
xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
if xdg_config_home:
return Path(xdg_config_home).expanduser() / "autostart"
return Path.home() / ".config" / "autostart"
def get_autostart_file() -> Path:
return get_autostart_dir() / DESKTOP_FILENAME
def get_exec_command() -> str:
installed_command = shutil.which("vocalinux")
if installed_command:
return f"{shlex.quote(installed_command)} --start-minimized"
if getattr(sys, "frozen", False):
return f"{shlex.quote(sys.executable)} --start-minimized"
return f"{shlex.quote(sys.executable)} -m vocalinux.main --start-minimized"
def is_autostart_enabled() -> bool:
"""Check if autostart is currently enabled."""
return get_autostart_file().exists()
def enable_autostart() -> bool:
"""Enable autostart by creating the desktop entry file."""
try:
autostart_dir = get_autostart_dir()
autostart_file = get_autostart_file()
autostart_dir.mkdir(parents=True, exist_ok=True)
exec_command = get_exec_command()
logger.info(f"Creating autostart entry with command: {exec_command}")
desktop_entry = f"""[Desktop Entry]
Version=1.0
Type=Application
Name=Vocalinux
Exec={exec_command}
Icon=vocalinux
Comment=Voice dictation for Linux
Terminal=false
StartupNotify=false
X-GNOME-Autostart-enabled=true
"""
with open(autostart_file, "w", encoding="utf-8") as f:
f.write(desktop_entry)
logger.info(f"Autostart enabled: {autostart_file}")
return True
except Exception as e:
logger.error(f"Failed to enable autostart: {e}")
return False
def disable_autostart() -> bool:
"""Disable autostart by removing the desktop entry file."""
try:
autostart_file = get_autostart_file()
if autostart_file.exists():
autostart_file.unlink()
logger.info(f"Autostart disabled: {autostart_file}")
return True
except Exception as e:
logger.error(f"Failed to disable autostart: {e}")
return False
def set_autostart(enabled: bool) -> bool:
"""Set autostart enabled or disabled."""
if enabled:
return enable_autostart()
return disable_autostart()
+4
View File
@@ -40,6 +40,10 @@ DEFAULT_CONFIG = {
"start_minimized": False,
"show_notifications": True,
},
"general": {
"autostart": False,
"first_run": True,
},
"advanced": {
"debug_logging": False,
"wayland_mode": False,
+121
View File
@@ -0,0 +1,121 @@
"""
First-run dialog for Vocalinux.
This module shows a dialog on first run to ask the user about autostart preferences.
"""
import logging
from typing import Optional
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk
logger = logging.getLogger(__name__)
class FirstRunDialog(Gtk.Dialog):
"""Dialog shown on first run to configure autostart preference."""
def __init__(self, parent: Optional[Gtk.Window] = None):
super().__init__(
title="Welcome to Vocalinux",
transient_for=parent,
flags=Gtk.DialogFlags.MODAL,
)
self.set_default_size(440, 280)
box = self.get_content_area()
box.set_spacing(16)
box.set_margin_start(24)
box.set_margin_end(24)
box.set_margin_top(24)
box.set_margin_bottom(20)
title_label = Gtk.Label(label="<b>Welcome to Vocalinux!</b>", use_markup=True)
title_label.set_justify(Gtk.Justification.CENTER)
box.pack_start(title_label, False, False, 0)
description_label = Gtk.Label(
label="Vocalinux runs from your system tray so you can start and stop voice typing anytime.",
wrap=True,
justify=Gtk.Justification.CENTER,
)
box.pack_start(description_label, False, False, 8)
quick_guide_label = Gtk.Label(
label=(
"<b>Quick start</b>\n"
"1. Open the tray icon and choose Start Voice Typing\n"
"2. Open Settings from the tray menu to configure shortcuts, model, and audio"
),
use_markup=True,
wrap=True,
justify=Gtk.Justification.LEFT,
xalign=0,
)
box.pack_start(quick_guide_label, False, False, 0)
subtitle_label = Gtk.Label(
label="Would you like Vocalinux to start automatically when you log in?",
wrap=True,
justify=Gtk.Justification.CENTER,
)
subtitle_label.get_style_context().add_class("preference-row-subtitle")
box.pack_start(subtitle_label, False, False, 0)
button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
button_box.set_halign(Gtk.Align.CENTER)
button_box.set_margin_top(16)
yes_button = Gtk.Button(label="Yes, start on login")
yes_button.get_style_context().add_class("suggested-action")
yes_button.connect("clicked", self._on_yes_clicked)
button_box.pack_start(yes_button, False, False, 0)
no_button = Gtk.Button(label="No, I'll start manually")
no_button.connect("clicked", self._on_no_clicked)
button_box.pack_start(no_button, False, False, 0)
later_button = Gtk.Button(label="Ask me later")
later_button.connect("clicked", self._on_later_clicked)
button_box.pack_start(later_button, False, False, 0)
box.pack_start(button_box, False, False, 0)
self.result = None
self.show_all()
def _on_yes_clicked(self, widget):
"""Handle Yes button click."""
self.result = "yes"
self.destroy()
def _on_no_clicked(self, widget):
"""Handle No button click."""
self.result = "no"
self.destroy()
def _on_later_clicked(self, widget):
"""Handle Later button click."""
self.result = "later"
self.destroy()
def show_first_run_dialog(parent: Optional[Gtk.Window] = None) -> Optional[str]:
"""
Show the first-run dialog and return the user's choice.
Args:
parent: The parent window for the dialog
Returns:
"yes" if user chose to enable autostart
"no" if user chose to disable autostart
"later" if user wants to be asked later
None if dialog was closed without making a choice
"""
dialog = FirstRunDialog(parent)
dialog.run()
return dialog.result
+80 -3
View File
@@ -722,6 +722,7 @@ class SettingsDialog(Gtk.Dialog):
self.get_content_area().pack_start(scrolled, True, True, 0)
# Build UI sections
self._build_general_section()
self._build_audio_section()
self._build_engine_section()
self._build_recognition_section()
@@ -807,6 +808,63 @@ class SettingsDialog(Gtk.Dialog):
self._populate_audio_devices()
self.audio_device_combo.connect("changed", self._on_audio_device_changed)
def _build_general_section(self):
"""Build the General section with autostart and UI settings."""
group = PreferencesGroup(title="General")
self.autostart_switch = Gtk.Switch()
self.autostart_switch.set_tooltip_text("Start Vocalinux automatically when you log in")
autostart_row = PreferenceRow(
title="Start on Login",
subtitle="Automatically start Vocalinux when you log in",
widget=self.autostart_switch,
)
group.add_row(autostart_row)
self.start_minimized_switch = Gtk.Switch()
self.start_minimized_switch.set_tooltip_text("Start minimized to system tray")
start_minimized_row = PreferenceRow(
title="Start Minimized",
subtitle="Start minimized to system tray instead of showing window",
widget=self.start_minimized_switch,
)
group.add_row(start_minimized_row)
self.content_box.pack_start(group, False, False, 0)
self.autostart_switch.connect("state-set", self._on_autostart_toggled)
self.start_minimized_switch.connect("state-set", self._on_start_minimized_toggled)
def _on_autostart_toggled(self, widget, state):
"""Handle toggle of the autostart switch."""
if self._initializing or self._applying_settings:
return False
enabled = bool(state)
logger.info(f"Autostart toggled: {enabled}")
from . import autostart_manager
if autostart_manager.set_autostart(enabled):
self.config_manager.set("general", "autostart", enabled)
self.config_manager.save_settings()
logger.info(f"Autostart {'enabled' if enabled else 'disabled'}")
return False
return True
def _on_start_minimized_toggled(self, widget, state):
"""Handle toggle of the start minimized switch."""
if self._initializing or self._applying_settings:
return False
enabled = bool(state)
logger.info(f"Start minimized toggled: {enabled}")
self.config_manager.set("ui", "start_minimized", enabled)
self.config_manager.save_settings()
logger.info(f"Start minimized {'enabled' if enabled else 'disabled'}")
return False
def _build_engine_section(self):
"""Build the Speech Engine section."""
group = PreferencesGroup(title="Speech Engine")
@@ -879,7 +937,11 @@ class SettingsDialog(Gtk.Dialog):
legend_box.set_margin_top(4)
legend_box.set_margin_bottom(4)
for symbol, text in [("✓", "Downloaded"), ("↓", "Will download"), ("★", "Recommended")]:
for symbol, text in [
("✓", "Downloaded"),
("↓", "Will download"),
("★", "Recommended"),
]:
item = Gtk.Label(label=f"{symbol} {text}")
item.get_style_context().add_class("status-info")
legend_box.pack_start(item, False, False, 0)
@@ -1109,6 +1171,15 @@ class SettingsDialog(Gtk.Dialog):
f"Starting dialog with settings: engine={self.current_engine}, model={self.current_model_size}"
)
general_settings = self.config_manager.get_settings().get("general", {})
ui_settings = self.config_manager.get_settings().get("ui", {})
autostart_enabled = general_settings.get("autostart", False)
start_minimized = ui_settings.get("start_minimized", False)
self.autostart_switch.set_active(autostart_enabled)
self.start_minimized_switch.set_active(start_minimized)
# Populate engine combo with only available engines
available_engines = get_available_engines()
available_count = 0
@@ -1513,10 +1584,16 @@ class SettingsDialog(Gtk.Dialog):
except Exception as e:
error_msg = str(e)
if "cancelled" in error_msg.lower():
GLib.idle_add(download_dialog.set_complete, False, "Download cancelled")
GLib.idle_add(
download_dialog.set_complete,
False,
"Download cancelled",
)
elif engine == "whisper" and "no module named" in error_msg.lower():
GLib.idle_add(
download_dialog.set_complete, False, "Whisper not installed"
download_dialog.set_complete,
False,
"Whisper not installed",
)
GLib.idle_add(self._show_whisper_install_dialog)
else:
+63 -1
View File
@@ -28,7 +28,11 @@ except (ImportError, ValueError):
from gi.repository import GdkPixbuf, GLib, GObject, Gtk
# Import local modules - Use protocols to avoid circular imports
from ..common_types import RecognitionState, SpeechRecognitionManagerProtocol, TextInjectorProtocol
from ..common_types import (
RecognitionState,
SpeechRecognitionManagerProtocol,
TextInjectorProtocol,
)
# Import necessary components
from .config_manager import ConfigManager # noqa: E402
@@ -77,6 +81,7 @@ class TrayIndicator:
self.speech_engine = speech_engine
self.text_injector = text_injector
self.config_manager = ConfigManager() # Added: Initialize ConfigManager
self._syncing_autostart_menu = False
# Get configured shortcut from config
shortcut = self.config_manager.get("shortcuts", "toggle_recognition", "ctrl+ctrl")
@@ -168,6 +173,13 @@ class TrayIndicator:
# Add menu items
self._add_menu_item("Start Voice Typing", self._on_start_clicked)
self._add_menu_item("Stop Voice Typing", self._on_stop_clicked)
self._add_menu_separator()
self._autostart_menu_item = self._add_menu_checkbox(
"Start on Login", self._on_autostart_toggled
)
self._update_autostart_checkbox()
self._add_menu_separator()
self._add_menu_item("Settings", self._on_settings_clicked)
self._add_menu_item("View Logs", self._on_logs_clicked)
@@ -211,6 +223,56 @@ class TrayIndicator:
separator = Gtk.SeparatorMenuItem()
self.menu.append(separator)
def _add_menu_checkbox(self, label: str, callback: Callable) -> Gtk.CheckMenuItem:
"""
Add a checkbox menu item to the indicator menu.
Args:
label: The label for the menu item
callback: The callback function to call when the item is toggled
Returns:
The checkbox menu item
"""
item = Gtk.CheckMenuItem.new_with_label(label)
item.connect("toggled", callback)
self.menu.append(item)
return item
def _update_autostart_checkbox(self):
"""Update the autostart checkbox state based on current config."""
from . import autostart_manager
autostart_enabled = autostart_manager.is_autostart_enabled()
config_enabled = self.config_manager.get("general", "autostart", False)
if config_enabled != autostart_enabled:
self.config_manager.set("general", "autostart", autostart_enabled)
self.config_manager.save_settings()
self._syncing_autostart_menu = True
self._autostart_menu_item.set_active(autostart_enabled)
self._syncing_autostart_menu = False
def _on_autostart_toggled(self, widget):
"""Handle toggle of the Start on Login menu item."""
if self._syncing_autostart_menu:
return
enabled = widget.get_active()
logger.info(f"Autostart toggled: {enabled}")
from . import autostart_manager
if autostart_manager.set_autostart(enabled):
self.config_manager.set("general", "autostart", enabled)
self.config_manager.save_settings()
status = "enabled" if enabled else "disabled"
logger.info(f"Autostart {status}")
else:
self._syncing_autostart_menu = True
widget.set_active(not enabled)
self._syncing_autostart_menu = False
def _on_recognition_state_changed(self, state: RecognitionState):
"""
Handle changes in the speech recognition state.
+46
View File
@@ -0,0 +1,46 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from vocalinux.ui import autostart_manager
class TestAutostartManager(unittest.TestCase):
def test_get_autostart_dir_uses_xdg_config_home(self):
with patch.dict("os.environ", {"XDG_CONFIG_HOME": "/tmp/vlx-config"}, clear=False):
autostart_dir = autostart_manager.get_autostart_dir()
self.assertEqual(autostart_dir, Path("/tmp/vlx-config") / "autostart")
def test_get_exec_command_prefers_installed_command(self):
with patch(
"vocalinux.ui.autostart_manager.shutil.which",
return_value="/usr/bin/vocalinux",
):
command = autostart_manager.get_exec_command()
self.assertEqual(command, "/usr/bin/vocalinux --start-minimized")
def test_get_exec_command_falls_back_to_python_module(self):
with patch("vocalinux.ui.autostart_manager.shutil.which", return_value=None), patch(
"vocalinux.ui.autostart_manager.sys.executable", "/usr/bin/python3"
), patch("vocalinux.ui.autostart_manager.sys.frozen", new=False, create=True):
command = autostart_manager.get_exec_command()
self.assertEqual(command, "/usr/bin/python3 -m vocalinux.main --start-minimized")
def test_enable_disable_autostart_creates_and_removes_entry(self):
with tempfile.TemporaryDirectory() as tmp_dir:
with patch.dict("os.environ", {"XDG_CONFIG_HOME": tmp_dir}, clear=False), patch(
"vocalinux.ui.autostart_manager.shutil.which",
return_value="/usr/bin/vocalinux",
):
enabled = autostart_manager.enable_autostart()
self.assertTrue(enabled)
desktop_file = Path(tmp_dir) / "autostart" / "vocalinux.desktop"
self.assertTrue(desktop_file.exists())
content = desktop_file.read_text(encoding="utf-8")
self.assertIn("Exec=/usr/bin/vocalinux --start-minimized", content)
disabled = autostart_manager.disable_autostart()
self.assertTrue(disabled)
self.assertFalse(desktop_file.exists())
+137 -5
View File
@@ -28,6 +28,7 @@ class TestMainModule(unittest.TestCase):
self.assertIsNone(args.engine)
self.assertIsNone(args.language)
self.assertFalse(args.wayland)
self.assertFalse(args.start_minimized)
def test_parse_arguments_custom(self):
"""Test argument parsing with custom values."""
@@ -44,6 +45,7 @@ class TestMainModule(unittest.TestCase):
"--language",
"fr",
"--wayland",
"--start-minimized",
],
):
args = parse_arguments()
@@ -52,6 +54,7 @@ class TestMainModule(unittest.TestCase):
self.assertEqual(args.engine, "whisper")
self.assertEqual(args.language, "fr")
self.assertTrue(args.wayland)
self.assertTrue(args.start_minimized)
def test_parse_arguments_model_choices(self):
"""Test that model only accepts valid choices."""
@@ -139,7 +142,9 @@ class TestMainModule(unittest.TestCase):
# Mock config
mock_config_instance = MagicMock()
mock_config_instance.get_settings.return_value = {}
mock_config_instance.get_settings.return_value = {
"general": {"first_run": False},
}
mock_config.return_value = mock_config_instance
# Make SpeechRecognitionManager raise an exception
@@ -176,7 +181,10 @@ class TestMainModule(unittest.TestCase):
# Mock ConfigManager to return empty settings (use command-line defaults)
mock_config_instance = MagicMock()
mock_config_instance.get_settings.return_value = {"speech_recognition": {}}
mock_config_instance.get_settings.return_value = {
"speech_recognition": {},
"general": {"first_run": False},
}
mock_config_manager.return_value = mock_config_instance
# Mock objects
@@ -254,7 +262,10 @@ class TestMainModule(unittest.TestCase):
# Mock ConfigManager
mock_config_instance = MagicMock()
mock_config_instance.get_settings.return_value = {"speech_recognition": {}}
mock_config_instance.get_settings.return_value = {
"speech_recognition": {},
"general": {"first_run": False},
}
mock_config_manager.return_value = mock_config_instance
# Mock objects
@@ -288,6 +299,116 @@ class TestMainModule(unittest.TestCase):
# Verify root logger had setLevel called with DEBUG
root_logger.setLevel.assert_called()
@patch("vocalinux.main.check_dependencies")
@patch("vocalinux.ui.action_handler.ActionHandler")
@patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager")
@patch("vocalinux.text_injection.text_injector.TextInjector")
@patch("vocalinux.ui.tray_indicator.TrayIndicator")
@patch("vocalinux.ui.first_run_dialog.show_first_run_dialog")
@patch("vocalinux.ui.config_manager.ConfigManager")
@patch("vocalinux.ui.logging_manager.initialize_logging")
def test_main_first_run_later_keeps_prompt_enabled(
self,
mock_init_logging,
mock_config_manager,
mock_first_run_dialog,
mock_tray,
mock_text,
mock_speech,
mock_action_handler,
mock_check_deps,
):
"""Selecting later on first-run does not disable future prompt."""
mock_check_deps.return_value = True
mock_first_run_dialog.return_value = "later"
mock_config_instance = MagicMock()
mock_config_instance.get_settings.return_value = {
"speech_recognition": {},
"general": {"first_run": True},
}
mock_config_manager.return_value = mock_config_instance
mock_speech.return_value = MagicMock()
mock_text.return_value = MagicMock()
mock_tray.return_value = MagicMock()
mock_action_instance = MagicMock()
mock_action_handler.return_value = mock_action_instance
with patch("vocalinux.main.parse_arguments") as mock_parse:
mock_args = MagicMock()
mock_args.debug = False
mock_args.model = "small"
mock_args.engine = "vosk"
mock_args.language = "en-us"
mock_args.wayland = False
mock_parse.return_value = mock_args
with patch("vocalinux.main.logger"):
main()
self.assertFalse(
any(
call.args == ("general", "first_run", False)
for call in mock_config_instance.set.call_args_list
)
)
mock_config_instance.save_settings.assert_not_called()
@patch("vocalinux.main.check_dependencies")
@patch("vocalinux.ui.action_handler.ActionHandler")
@patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager")
@patch("vocalinux.text_injection.text_injector.TextInjector")
@patch("vocalinux.ui.tray_indicator.TrayIndicator")
@patch("vocalinux.ui.first_run_dialog.show_first_run_dialog")
@patch("vocalinux.ui.config_manager.ConfigManager")
@patch("vocalinux.ui.logging_manager.initialize_logging")
def test_main_start_minimized_skips_first_run_prompt(
self,
mock_init_logging,
mock_config_manager,
mock_first_run_dialog,
mock_tray,
mock_text,
mock_speech,
mock_action_handler,
mock_check_deps,
):
mock_check_deps.return_value = True
mock_config_instance = MagicMock()
mock_config_instance.get_settings.return_value = {
"speech_recognition": {},
"general": {"first_run": True},
}
mock_config_manager.return_value = mock_config_instance
mock_speech.return_value = MagicMock()
mock_text.return_value = MagicMock()
mock_tray.return_value = MagicMock()
mock_action_handler.return_value = MagicMock()
with patch("vocalinux.main.parse_arguments") as mock_parse:
mock_args = MagicMock()
mock_args.debug = False
mock_args.model = "small"
mock_args.engine = "vosk"
mock_args.language = "en-us"
mock_args.wayland = False
mock_args.start_minimized = True
mock_parse.return_value = mock_args
with patch("vocalinux.main.logger"):
main()
mock_first_run_dialog.assert_not_called()
self.assertFalse(
any(
call.args[0:2] == ("general", "first_run")
for call in mock_config_instance.set.call_args_list
)
)
class TestCheckDependencies(unittest.TestCase):
"""Test cases for check_dependencies function."""
@@ -429,7 +550,8 @@ class TestMainConfigPrecedence(unittest.TestCase):
"engine": "vosk",
"model_size": "small",
"language": "en-us",
}
},
"general": {"first_run": False},
}
mock_config_manager.return_value = mock_config_instance
@@ -445,7 +567,16 @@ class TestMainConfigPrecedence(unittest.TestCase):
# Simulate CLI args being set
with patch(
"sys.argv", ["vocalinux", "--engine", "whisper", "--model", "large", "--language", "fr"]
"sys.argv",
[
"vocalinux",
"--engine",
"whisper",
"--model",
"large",
"--language",
"fr",
],
):
with patch("vocalinux.main.logger"):
main()
@@ -488,6 +619,7 @@ class TestMainConfigPrecedence(unittest.TestCase):
"audio": {
"device_index": 2,
},
"general": {"first_run": False},
}
mock_config_manager.return_value = mock_config_instance
+38 -3
View File
@@ -27,6 +27,30 @@ command_exists() {
command -v "$1" >/dev/null 2>&1
}
get_vocalinux_pids() {
pgrep -f "vocalinux" 2>/dev/null | while read -r pid; do
[ -z "$pid" ] && continue
if [ "$pid" = "$$" ] || [ "$pid" = "$PPID" ]; then
continue
fi
local stat
stat=$(ps -o stat= -p "$pid" 2>/dev/null | awk '{print $1}')
if [[ "$stat" == Z* ]]; then
continue
fi
local cmd
cmd=$(ps -o args= -p "$pid" 2>/dev/null)
if [[ "$cmd" == *"install.sh"* ]] || [[ "$cmd" == *"uninstall.sh"* ]]; then
continue
fi
echo "$pid"
done
}
# Function to safely remove a file or directory
safe_remove() {
local path="$1"
@@ -51,7 +75,8 @@ safe_remove() {
kill_vocalinux_processes() {
print_info "Checking for running Vocalinux processes..."
local PIDS=$(pgrep -f "vocalinux" 2>/dev/null || true)
local PIDS
PIDS=$(get_vocalinux_pids || true)
if [ -n "$PIDS" ]; then
print_warning "Found running Vocalinux process(es): $PIDS"
@@ -69,7 +94,8 @@ kill_vocalinux_processes() {
echo "$PIDS" | xargs -r kill -TERM 2>/dev/null || true
sleep 2
local REMAINING_PIDS=$(pgrep -f "vocalinux" 2>/dev/null || true)
local REMAINING_PIDS
REMAINING_PIDS=$(get_vocalinux_pids || true)
if [ -n "$REMAINING_PIDS" ]; then
print_warning "Some processes still running, forcing termination..."
@@ -77,7 +103,8 @@ kill_vocalinux_processes() {
sleep 1
fi
local FINAL_PIDS=$(pgrep -f "vocalinux" 2>/dev/null || true)
local FINAL_PIDS
FINAL_PIDS=$(get_vocalinux_pids || true)
if [ -n "$FINAL_PIDS" ]; then
print_error "Warning: Could not terminate all Vocalinux processes: $FINAL_PIDS"
print_error "You may need to manually kill these processes before continuing."
@@ -145,6 +172,7 @@ done
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/vocalinux"
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/vocalinux"
DESKTOP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
AUTOSTART_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/autostart"
ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/scalable/apps"
# Directories created by curl-based installation
@@ -233,6 +261,8 @@ remove_application_files() {
# Remove desktop entry
safe_remove "$DESKTOP_DIR/vocalinux.desktop" "desktop entry"
safe_remove "$AUTOSTART_DIR/vocalinux.desktop" "autostart desktop entry"
# Remove icons
print_info "Removing application icons..."
@@ -337,6 +367,11 @@ verify_uninstallation() {
print_warning "Desktop entry still exists: $DESKTOP_DIR/vocalinux.desktop"
((ISSUES++))
fi
if [ -f "$AUTOSTART_DIR/vocalinux.desktop" ]; then
print_warning "Autostart desktop entry still exists: $AUTOSTART_DIR/vocalinux.desktop"
((ISSUES++))
fi
# Check if any icons still exist
local ICON_COUNT=0