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:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user