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