diff --git a/README.md b/README.md index 1720456..28857ab 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/DISTRO_COMPATIBILITY.md b/docs/DISTRO_COMPATIBILITY.md index ef7ffa2..4753a2f 100644 --- a/docs/DISTRO_COMPATIBILITY.md +++ b/docs/DISTRO_COMPATIBILITY.md @@ -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 | diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 8e47b8e..0530cae 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -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): diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index b04405a..9440c44 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -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 diff --git a/install.sh b/install.sh index d6fbac7..4855693 100755 --- a/install.sh +++ b/install.sh @@ -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." diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py index 31ebfce..589396b 100644 --- a/src/vocalinux/main.py +++ b/src/vocalinux/main.py @@ -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 diff --git a/src/vocalinux/ui/autostart_manager.py b/src/vocalinux/ui/autostart_manager.py new file mode 100644 index 0000000..261f90c --- /dev/null +++ b/src/vocalinux/ui/autostart_manager.py @@ -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() diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index da155b3..408f42c 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -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, diff --git a/src/vocalinux/ui/first_run_dialog.py b/src/vocalinux/ui/first_run_dialog.py new file mode 100644 index 0000000..f6bf83a --- /dev/null +++ b/src/vocalinux/ui/first_run_dialog.py @@ -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="Welcome to Vocalinux!", 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=( + "Quick start\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 diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 89019e0..853034e 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -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: diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index 4b28f88..ab38793 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -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. diff --git a/tests/test_autostart_manager.py b/tests/test_autostart_manager.py new file mode 100644 index 0000000..2f89f30 --- /dev/null +++ b/tests/test_autostart_manager.py @@ -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()) diff --git a/tests/test_main.py b/tests/test_main.py index 4b0b779..583c4ce 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -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 diff --git a/uninstall.sh b/uninstall.sh index 38366bc..459a53a 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -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